Skip to content

Commit deff4ef

Browse files
authored
Add onRender hook (#795)
1 parent 47094ec commit deff4ef

File tree

4 files changed

+116
-3
lines changed

4 files changed

+116
-3
lines changed

readme.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,6 +2035,13 @@ That way, both are visible and don't overlap each other.
20352035

20362036
This functionality is powered by [patch-console](https://github.com/vadimdemedes/patch-console), so if you need to disable Ink's interception of output but want to build something custom, you can use that.
20372037

2038+
###### onRender
2039+
2040+
Type: `(renderTime: number) => void`\
2041+
Default: `undefined`
2042+
2043+
Runs the given callback after each render and re-render with a renderResult object.
2044+
20382045
###### debug
20392046

20402047
Type: `boolean`\

src/ink.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,18 @@ import {accessibilityContext as AccessibilityContext} from './components/Accessi
2020

2121
const noop = () => {};
2222

23+
export type RenderResult = {
24+
renderTime: number;
25+
};
26+
2327
export type Options = {
2428
stdout: NodeJS.WriteStream;
2529
stdin: NodeJS.ReadStream;
2630
stderr: NodeJS.WriteStream;
2731
debug: boolean;
2832
exitOnCtrlC: boolean;
2933
patchConsole: boolean;
34+
onRender?: (renderTime: RenderResult) => void;
3035
isScreenReaderEnabled?: boolean;
3136
waitUntilExit?: () => Promise<void>;
3237
maxFps?: number;
@@ -163,10 +168,14 @@ export default class Ink {
163168
return;
164169
}
165170

171+
const startTime = performance.now();
166172
const {output, outputHeight, staticOutput} = render(
167173
this.rootNode,
168174
this.isScreenReaderEnabled,
169175
);
176+
if (this.options.onRender) {
177+
this.options.onRender({renderTime: performance.now() - startTime});
178+
}
170179

171180
// If <Static> output isn't empty, it means new children have been added to it
172181
const hasStaticOutput = staticOutput && staticOutput !== '\n';
@@ -278,12 +287,17 @@ export default class Ink {
278287
</AccessibilityContext.Provider>
279288
);
280289

290+
const start = performance.now();
281291
// @ts-expect-error the types for `react-reconciler` are not up to date with the library.
282292
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
283293
reconciler.updateContainerSync(tree, this.container, null, noop);
284294
// @ts-expect-error the types for `react-reconciler` are not up to date with the library.
285295
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
286296
reconciler.flushSyncWork();
297+
if (this.options.onRender) {
298+
const end = performance.now();
299+
this.options.onRender({renderTime: end - start});
300+
}
287301
}
288302

289303
writeToStdout(data: string): void {

src/render.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {Stream} from 'node:stream';
22
import process from 'node:process';
33
import type {ReactNode} from 'react';
4-
import Ink, {type Options as InkOptions} from './ink.js';
4+
import Ink, {type Options as InkOptions, type RenderResult} from './ink.js';
55
import instances from './instances.js';
66

77
export type RenderOptions = {
@@ -46,6 +46,11 @@ export type RenderOptions = {
4646
*/
4747
patchConsole?: boolean;
4848

49+
/**
50+
Runs the given callback after each render and re-render with a RenderResult object.
51+
*/
52+
onRender?: (renderTime: RenderResult) => void;
53+
4954
/**
5055
Enable screen reader support. See https://github.com/vadimdemedes/ink/blob/master/readme.md#screen-reader-support
5156

test/render.tsx

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
import EventEmitter from 'node:events';
12
import process from 'node:process';
23
import url from 'node:url';
34
import * as path from 'node:path';
45
import {createRequire} from 'node:module';
56
import FakeTimers from '@sinonjs/fake-timers';
7+
import {stub} from 'sinon';
68
import test from 'ava';
7-
import React from 'react';
9+
import React, {useEffect, useState} from 'react';
810
import ansiEscapes from 'ansi-escapes';
911
import stripAnsi from 'strip-ansi';
1012
import boxen from 'boxen';
1113
import delay from 'delay';
12-
import {render, Box, Text} from '../src/index.js';
14+
import {render, Box, Text, useInput} from '../src/index.js';
15+
import {type RenderResult} from '../src/ink.js';
1316
import createStdout from './helpers/create-stdout.js';
1417

1518
const require = createRequire(import.meta.url);
@@ -19,6 +22,28 @@ const {spawn} = require('node-pty') as typeof import('node-pty');
1922

2023
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
2124

25+
const createStdin = () => {
26+
const stdin = new EventEmitter() as unknown as NodeJS.WriteStream;
27+
stdin.isTTY = true;
28+
stdin.setRawMode = stub();
29+
stdin.setEncoding = () => {};
30+
stdin.read = stub();
31+
stdin.unref = () => {};
32+
stdin.ref = () => {};
33+
34+
return stdin;
35+
};
36+
37+
const emitReadable = (stdin: NodeJS.WriteStream, chunk: string) => {
38+
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */
39+
const read = stdin.read as ReturnType<typeof stub>;
40+
read.onCall(0).returns(chunk);
41+
read.onCall(1).returns(null);
42+
stdin.emit('readable');
43+
read.reset();
44+
/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */
45+
};
46+
2247
const term = (fixture: string, args: string[] = []) => {
2348
let resolve: (value?: unknown) => void;
2449
let reject: (error: Error) => void;
@@ -274,6 +299,68 @@ test.serial('throttle renders to maxFps', t => {
274299
}
275300
});
276301

302+
test.serial('outputs renderTime when onRender is passed', async t => {
303+
const clock = FakeTimers.install(); // Controls timers + Date.now()
304+
let lastRenderTime = -1;
305+
let tickTime = 100;
306+
const funcObj = {
307+
onRender(renderResult: RenderResult) {
308+
const {renderTime} = renderResult;
309+
lastRenderTime = renderTime;
310+
},
311+
};
312+
313+
const onRenderStub = stub(funcObj, 'onRender').callThrough();
314+
315+
function Nested({text}) {
316+
clock.tick(tickTime);
317+
return <Text>{text}</Text>;
318+
}
319+
320+
function Test() {
321+
const [text, setText] = useState('Test');
322+
useEffect(() => {
323+
clock.tick(tickTime);
324+
});
325+
326+
useInput(input => {
327+
setText(input);
328+
});
329+
330+
return (
331+
<Box borderStyle="round">
332+
<Text>{text}</Text>
333+
<Nested text={text} />
334+
</Box>
335+
);
336+
}
337+
338+
const stdin = createStdin();
339+
const {unmount, rerender} = render(<Test />, {
340+
onRender: onRenderStub,
341+
stdin,
342+
});
343+
344+
t.is(onRenderStub.callCount, 2);
345+
346+
onRenderStub.resetHistory();
347+
tickTime = 200;
348+
rerender(<Test />);
349+
350+
t.is(onRenderStub.callCount, 2);
351+
352+
onRenderStub.resetHistory();
353+
emitReadable(stdin, 'a');
354+
await delay(100);
355+
356+
t.is(lastRenderTime, 0);
357+
t.is(onRenderStub.callCount, 1);
358+
359+
unmount();
360+
361+
clock.uninstall();
362+
});
363+
277364
test.serial('no throttled renders after unmount', t => {
278365
const clock = FakeTimers.install();
279366
try {

0 commit comments

Comments
 (0)