Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2593,23 +2593,27 @@ Type: `ReactNode`

Type: `object`

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.

`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.

###### stdout

Type: `stream.Writable`\
Type: `InkOutputStream`\
Default: `process.stdout`

Output stream where the app will be rendered.

###### stdin

Type: `stream.Readable`\
Type: `InkInputStream`\
Default: `process.stdin`

Input stream where app will listen for input.

###### stderr

Type: `stream.Writable`\
Type: `InkOutputStream`\
Default: `process.stderr`

Error stream.
Expand Down
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export type {RenderOptions, Instance} from './render.js';
export type {
RenderOptions,
Instance,
InkOutputStream,
InkInputStream,
} from './render.js';
export {default as render} from './render.js';
export type {RenderToStringOptions} from './render-to-string.js';
export {default as renderToString} from './render-to-string.js';
Expand Down
72 changes: 57 additions & 15 deletions src/render.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,69 @@
import {Stream} from 'node:stream';
import {Stream, type Writable} from 'node:stream';
import process from 'node:process';
import type {ReactNode} from 'react';
import Ink, {type Options as InkOptions, type RenderMetrics} from './ink.js';
import instances from './instances.js';
import {type KittyKeyboardOptions} from './kitty-keyboard.js';

export type RenderOptions = {
/**
Output stream that Ink can render to, like `process.stdout` or a stream that captures output in memory.

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()` and `waitUntilRenderFlush()` never settle.
*/
export type InkOutputStream = {
columns?: number;
rows?: number;
isTTY?: boolean;
destroyed?: boolean;
writable?: boolean;
writableEnded?: boolean;
writableLength?: number;
write(data: string, ...rest: unknown[]): unknown;
on?(event: unknown, listener: unknown): unknown;
off?(event: unknown, listener: unknown): unknown;
};

/**
Input stream that Ink can listen for input on, like `process.stdin`.

Every member is optional, because Ink only touches stdin when `isTTY` is set. Raw mode input then calls `addListener()`, `read()`, `setRawMode()`, `setEncoding()`, `ref()` and `unref()`, and kitty keyboard detection also uses `on()`, `removeListener()` and `unshift()`.
*/
export type InkInputStream = {
isTTY?: boolean;
on?(event: unknown, listener: unknown): unknown;
read?(...args: unknown[]): unknown;
setRawMode?(mode: boolean): unknown;
setEncoding?(...args: unknown[]): unknown;
unshift?(...args: unknown[]): unknown;
addListener?(event: unknown, listener: unknown): unknown;
removeListener?(event: unknown, listener: unknown): unknown;
ref?(): unknown;
unref?(): unknown;
};

export type RenderOptions<
OutputStream extends InkOutputStream = NodeJS.WriteStream,
InputStream extends InkInputStream = NodeJS.ReadStream,
> = {
/**
Output stream where the app will be rendered.

@default process.stdout
*/
stdout?: NodeJS.WriteStream;
stdout?: OutputStream;

/**
Input stream where app will listen for input.

@default process.stdin
*/
stdin?: NodeJS.ReadStream;
stdin?: InputStream;

/**
Error stream.
@default process.stderr
*/
stderr?: NodeJS.WriteStream;
stderr?: OutputStream;

/**
If true, each update will be rendered as separate output, without replacing the previous one.
Expand Down Expand Up @@ -198,20 +237,22 @@ Mount a component and render the output.
*/
const render = (
node: ReactNode,
options?: NodeJS.WriteStream | RenderOptions,
options?: Writable | RenderOptions<InkOutputStream, InkInputStream>,
): Instance => {
const {stdout, stdin, stderr, ...restOptions} = {...getOptions(options)};

const inkOptions: InkOptions = {
stdout: process.stdout,
stdin: process.stdin,
stderr: process.stderr,
debug: false,
exitOnCtrlC: true,
patchConsole: true,
maxFps: 30,
incrementalRendering: false,
concurrent: false,
alternateScreen: false,
...getOptions(options),
...restOptions,
stdout: (stdout ?? process.stdout) as NodeJS.WriteStream,
stdin: (stdin ?? process.stdin) as NodeJS.ReadStream,
stderr: (stderr ?? process.stderr) as NodeJS.WriteStream,
};

const instance: Ink = getInstance(
Expand All @@ -237,16 +278,17 @@ const render = (
export default render;

const getOptions = (
stdout: NodeJS.WriteStream | RenderOptions | undefined = {},
): RenderOptions => {
if (stdout instanceof Stream) {
options:
Writable | RenderOptions<InkOutputStream, InkInputStream> | undefined = {},
): RenderOptions<InkOutputStream, InkInputStream> => {
if (options instanceof Stream) {
return {
stdout,
stdout: options,
stdin: process.stdin,
};
}

return stdout;
return options ?? {};
};

const getInstance = (
Expand Down
27 changes: 6 additions & 21 deletions test/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1102,8 +1102,7 @@ test('disable raw mode when all input components are unmounted', async t => {

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

t.true(stdin.setRawMode.calledOnce);
Expand Down Expand Up @@ -1158,11 +1157,7 @@ test('do not disable raw mode when swapping components that use useInput', async
return step === 1 ? <StepA /> : <StepB />;
}

const {rerender} = render(
<Test step={1} />,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
options as any,
);
const {rerender} = render(<Test step={1} />, options);

t.true(stdin.setRawMode.calledOnce);
t.true(stdin.ref.calledOnce);
Expand Down Expand Up @@ -1216,11 +1211,7 @@ test('clear pending input parser state when swapping components that use useInpu
return step === 1 ? <StepA /> : <StepB />;
}

const {rerender} = render(
<Test step={1} />,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
options as any,
);
const {rerender} = render(<Test step={1} />, options);

emitReadable(stdin, '\u001B[');
rerender(<Test step={2} />);
Expand Down Expand Up @@ -1271,11 +1262,7 @@ test('re-ref stdin when input is used after previous unmount', t => {
const onSecondMountInput = spy();

// First render
const {unmount} = render(
<Test onInput={onFirstMountInput} />,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
options as any,
);
const {unmount} = render(<Test onInput={onFirstMountInput} />, options);

t.true(stdin.ref.calledOnce);
t.true(stdin.setRawMode.calledOnce);
Expand All @@ -1294,8 +1281,7 @@ test('re-ref stdin when input is used after previous unmount', t => {
// Second render with new Ink instance reusing the same stdin
const {unmount: unmount2} = render(
<Test onInput={onSecondMountInput} />,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
options as any,
options,
);

t.true(stdin.ref.calledTwice);
Expand Down Expand Up @@ -1413,8 +1399,7 @@ test('render different component based on whether stdin is a TTY or not', t => {

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

t.false(stdin.setRawMode.called);
Expand Down
96 changes: 95 additions & 1 deletion test/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,17 @@ import ansiEscapes from 'ansi-escapes';
import stripAnsi from 'strip-ansi';
import boxen from 'boxen';
import delay from 'delay';
import {render, Box, Text, useApp, useCursor, useInput} from '../src/index.js';
import {
render,
Box,
Text,
useApp,
useCursor,
useInput,
type RenderOptions,
type InkOutputStream,
type InkInputStream,
} from '../src/index.js';
import {type RenderMetrics} from '../src/ink.js';
import {bsu, esu} from '../src/write-synchronized.js';
import {createStdin, emitReadable} from './helpers/create-stdin.js';
Expand Down Expand Up @@ -2111,3 +2121,87 @@ test.serial('bsu/esu wraps throttledLog trailing call', t => {
}
});
});

const createCaptureStream = () => {
const writes: string[] = [];

return {
columns: 100,
rows: 10,
write(data: string) {
writes.push(data);
},
output: () => writes.join(''),
};
};

test.serial('accept Node streams in render options', t => {
const options: RenderOptions = {
stdout: process.stdout,
stdin: process.stdin,
stderr: process.stderr,
};

t.is(options.stdout, process.stdout);
t.is(options.stdin, process.stdin);
t.is(options.stderr, process.stderr);
});

test.serial('render to a stream that only implements what Ink uses', t => {
const stdout = createCaptureStream();
const stderr = createCaptureStream();
const options: RenderOptions<InkOutputStream, InkInputStream> = {
stdout,
stderr,
stdin: {},
};

const {unmount} = render(<Text>Hello</Text>, options);
unmount();

t.true(stdout.output().includes('Hello'));
});

test.serial('render to a stream in debug mode', t => {
const stdout = createCaptureStream();

const {unmount} = render(<Text>Hello</Text>, {stdout, debug: true});
t.true(stdout.output().includes('Hello'));

unmount();
});

test.serial('treat an options object with a write method as options', t => {
const stdout = createCaptureStream();
const optionsWrites: string[] = [];
const options = {
stdout,
write(data: string) {
optionsWrites.push(data);
},
} as unknown as RenderOptions<InkOutputStream, InkInputStream>;

const {unmount} = render(<Text>Hello</Text>, options);
unmount();

t.true(stdout.output().includes('Hello'));
t.deepEqual(optionsWrites, []);
});

test.serial(
'render to a Node stream passed as the second argument',
async t => {
const stdout = new PassThrough();
let output = '';

stdout.on('data', (chunk: Uint8Array) => {
output += textDecoder.decode(chunk);
});

const {unmount} = render(<Text>Hello</Text>, stdout);
unmount();
await delay(0);

t.true(output.includes('Hello'));
},
);