Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Render last frame only when there is no output TTY #669

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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
9 changes: 7 additions & 2 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Props = {
readonly writeToStderr: (data: string) => void;
readonly exitOnCtrlC: boolean;
readonly onExit: (error?: Error) => void;
readonly hasCursor: boolean;
};

type State = {
Expand Down Expand Up @@ -127,11 +128,15 @@ export default class App extends PureComponent<Props, State> {
}

override componentDidMount() {
cliCursor.hide(this.props.stdout);
if (this.props.hasCursor) {
cliCursor.hide(this.props.stdout);
}
}

override componentWillUnmount() {
cliCursor.show(this.props.stdout);
if (this.props.hasCursor) {
cliCursor.show(this.props.stdout);
}

// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
Expand Down
21 changes: 13 additions & 8 deletions src/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type Options = {
debug: boolean;
exitOnCtrlC: boolean;
patchConsole: boolean;
renderLastFrameOnly?: boolean;
waitUntilExit?: () => Promise<void>;
};

Expand All @@ -40,6 +41,9 @@ export default class Ink {
// This variable is used only in debug mode to store full static output
// so that it's rerendered every time, not just new static parts, like in non-debug mode
private fullStaticOutput: string;
// CI environments and piped output don't handle erasing ansi escapes well,
// so it's better to only render last frame of non-static output
private readonly renderLastFrameOnly: boolean;
private exitPromise?: Promise<void>;
private restoreConsole?: () => void;
private readonly unsubscribeResize?: () => void;
Expand All @@ -66,6 +70,8 @@ export default class Ink {
leading: true,
trailing: true,
});
this.renderLastFrameOnly =
options.renderLastFrameOnly ?? (isInCi || !options.stdout.isTTY);

// Ignore last render after unmounting a tree to prevent empty output before exit
this.isUnmounted = false;
Expand Down Expand Up @@ -107,7 +113,7 @@ export default class Ink {
this.patchConsole();
}

if (!isInCi) {
if (!this.renderLastFrameOnly) {
options.stdout.on('resize', this.resized);

this.unsubscribeResize = () => {
Expand Down Expand Up @@ -158,7 +164,7 @@ export default class Ink {
return;
}

if (isInCi) {
if (this.renderLastFrameOnly) {
if (hasStaticOutput) {
this.options.stdout.write(staticOutput);
}
Expand Down Expand Up @@ -202,6 +208,7 @@ export default class Ink {
writeToStdout={this.writeToStdout}
writeToStderr={this.writeToStderr}
exitOnCtrlC={this.options.exitOnCtrlC}
hasCursor={!this.options.debug && !this.renderLastFrameOnly}
onExit={this.unmount}
>
{node}
Expand All @@ -221,7 +228,7 @@ export default class Ink {
return;
}

if (isInCi) {
if (this.renderLastFrameOnly) {
this.options.stdout.write(data);
return;
}
Expand All @@ -242,7 +249,7 @@ export default class Ink {
return;
}

if (isInCi) {
if (this.renderLastFrameOnly) {
this.options.stderr.write(data);
return;
}
Expand Down Expand Up @@ -270,9 +277,7 @@ export default class Ink {
this.unsubscribeResize();
}

// CIs don't handle erasing ansi escapes well, so it's better to
// only render last frame of non-static output
if (isInCi) {
if (this.renderLastFrameOnly) {
this.options.stdout.write(this.lastOutput + '\n');
} else if (!this.options.debug) {
this.log.done();
Expand Down Expand Up @@ -300,7 +305,7 @@ export default class Ink {
}

clear(): void {
if (!isInCi && !this.options.debug) {
if (!this.renderLastFrameOnly && !this.options.debug) {
this.log.clear();
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export type RenderOptions = {
* @default true
*/
patchConsole?: boolean;

/**
* Configure whether Ink should only render the last frame of non-static output. Useful when ANSI erase codes are not supported.
*
* @default true if in CI or stdout is not a TTY
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@default should only receive an actual TS value, not a sentence. Just use Default: ... instead.

*/
renderLastFrameOnly?: boolean;
};

export type Instance = {
Expand Down
111 changes: 108 additions & 3 deletions test/components.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import EventEmitter from 'node:events';
import test from 'ava';
import chalk from 'chalk';
import React, {Component, useState} from 'react';
import {spy} from 'sinon';
import React, {Component, useEffect, useState} from 'react';
import {spy, type SinonSpy} from 'sinon';
import ansiEscapes from 'ansi-escapes';
import {
Box,
Expand Down Expand Up @@ -645,7 +645,7 @@ test('render only last frame when run in CI', async t => {
t.true(output.includes('Counter: 5'));
});

test('render all frames if CI environment variable equals false', async t => {
test('render all frames if CI environment variable equals false and there is an output TTY', async t => {
const output = await run('ci', {
// eslint-disable-next-line @typescript-eslint/naming-convention
env: {CI: 'false'},
Expand All @@ -657,6 +657,111 @@ test('render all frames if CI environment variable equals false', async t => {
}
});

function TestLastFrame(props: {
readonly continueTest: () => void;
readonly message1: string;
readonly message2: string;
}) {
const [message, setMessage] = useState(props.message1);
useEffect(() => {
setTimeout(() => {
setMessage(props.message2);
props.continueTest();
}, 1);
}, [message]);
return <Text>{message}</Text>;
}

test('render only last frame when run without an output tty', async t => {
// Create without a TTY
const stdout = createStdout(undefined, false);
const message1 = 'Hello';
const message2 = 'World';

let continueTest = () => {};
const synchronizer = new Promise<void>((resolve, _reject) => {
continueTest = resolve;
});

const {unmount} = render(
<TestLastFrame
continueTest={continueTest}
message1={message1}
message2={message2}
/>,
{
stdout,
},
);
await synchronizer;
unmount();

const writeSpy = stdout.write as SinonSpy;
t.true(writeSpy.calledOnce);
t.true((writeSpy.firstCall.args[0] as string).includes(message2));
});

test('render only last frame when enabled in options', async t => {
const stdout = createStdout();
const message1 = 'Hello';
const message2 = 'World';

let continueTest = () => {};
const synchronizer = new Promise<void>((resolve, _reject) => {
continueTest = resolve;
});

const {unmount} = render(
<TestLastFrame
continueTest={continueTest}
message1={message1}
message2={message2}
/>,
{
stdout,
renderLastFrameOnly: true,
},
);
await synchronizer;
unmount();

const writeSpy = stdout.write as SinonSpy;
t.true(writeSpy.calledOnce);
t.true((writeSpy.firstCall.args[0] as string).includes(message2));
});

test('render all frames when enabled in options', async t => {
// Create without a TTY so last frame is the default
const stdout = createStdout(undefined, false);
const message1 = 'Hello';
const message2 = 'World';

let continueTest = () => {};
const synchronizer = new Promise<void>((resolve, _reject) => {
continueTest = resolve;
});

const {unmount} = render(
<TestLastFrame
continueTest={continueTest}
message1={message1}
message2={message2}
/>,
{
stdout,
// Override the default
renderLastFrameOnly: false,
},
);
await synchronizer;
unmount();

const writeSpy = stdout.write as SinonSpy;
t.true(writeSpy.calledTwice);
t.true((writeSpy.firstCall.args[0] as string).includes(message1));
t.true((writeSpy.secondCall.args[0] as string).includes(message2));
});

test('reset prop when it’s removed from the element', t => {
const stdout = createStdout();

Expand Down
7 changes: 5 additions & 2 deletions test/errors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ test('catch and display error', t => {
throw new Error('Oh no');
};

render(<Test />, {stdout});
render(<Test />, {
stdout,
debug: true,
});

t.deepEqual(
stripAnsi((stdout.write as any).lastCall.args[0] as string)
Expand All @@ -40,7 +43,7 @@ test('catch and display error', t => {
" 22: throw new Error('Oh no');",
' 23: };',
' 24:',
' 25: render(<Test />, {stdout});',
' 25: render(<Test />, {',
'',
' - Test (test/errors.tsx:22:9)',
],
Expand Down
3 changes: 2 additions & 1 deletion test/helpers/create-stdout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ type FakeStdout = {
get: () => string;
} & NodeJS.WriteStream;

const createStdout = (columns?: number): FakeStdout => {
const createStdout = (columns?: number, isTTY?: boolean): FakeStdout => {
const stdout = new EventEmitter() as unknown as FakeStdout;
stdout.columns = columns ?? 100;

const write = spy();
stdout.write = write;
stdout.isTTY = isTTY ?? true;

stdout.get = () => write.lastCall.args[0] as string;

Expand Down