Skip to content

Commit d71d019

Browse files
committed
feat: incremental rendering
1 parent 6d84457 commit d71d019

File tree

3 files changed

+140
-9
lines changed

3 files changed

+140
-9
lines changed

src/log-update.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export type LogUpdate = {
1010
};
1111

1212
const create = (stream: Writable, {showCursor = false} = {}): LogUpdate => {
13-
let previousLineCount = 0;
13+
let previousLines: string[] = [];
1414
let previousOutput = '';
1515
let hasHiddenCursor = false;
1616

@@ -25,20 +25,63 @@ const create = (stream: Writable, {showCursor = false} = {}): LogUpdate => {
2525
return;
2626
}
2727

28+
const previousLineCount = previousLines.length;
29+
const lines = output.split('\n');
30+
const lineCount = lines.length;
31+
32+
if (lineCount === 0 || previousLineCount === 0) {
33+
stream.write(ansiEscapes.eraseLines(previousLineCount) + output);
34+
previousOutput = output;
35+
previousLines = lines;
36+
return;
37+
}
38+
39+
// We aggregate all chunks for incremental rendering into a buffer, and then write them to stdout at the end.
40+
const buffer: string[] = [];
41+
const commitBuffer = () => {
42+
stream.write(buffer.join(''));
43+
};
44+
45+
// Clear extra lines if the current content's line count is lower than the previous.
46+
if (lineCount < previousLineCount) {
47+
buffer.push(
48+
ansiEscapes.eraseLines(previousLineCount - lineCount + 1),
49+
ansiEscapes.cursorUp(lineCount),
50+
);
51+
} else {
52+
buffer.push(ansiEscapes.cursorUp(previousLineCount));
53+
}
54+
55+
for (let i = 0; i < lineCount; i++) {
56+
// We do not write lines if the contents are the same. This prevents flickering during renders.
57+
if (lines[i] === previousLines[i]) {
58+
buffer.push(ansiEscapes.cursorNextLine);
59+
continue;
60+
}
61+
62+
if (i === lineCount - 1) {
63+
buffer.push(ansiEscapes.eraseLine);
64+
break;
65+
}
66+
67+
buffer.push(ansiEscapes.eraseLine + (lines[i] ?? '') + '\n');
68+
}
69+
70+
commitBuffer();
71+
2872
previousOutput = output;
29-
stream.write(ansiEscapes.eraseLines(previousLineCount) + output);
30-
previousLineCount = output.split('\n').length;
73+
previousLines = lines;
3174
};
3275

3376
render.clear = () => {
34-
stream.write(ansiEscapes.eraseLines(previousLineCount));
77+
stream.write(ansiEscapes.eraseLines(previousLines.length));
3578
previousOutput = '';
36-
previousLineCount = 0;
79+
previousLines = [];
3780
};
3881

3982
render.done = () => {
4083
previousOutput = '';
41-
previousLineCount = 0;
84+
previousLines = [];
4285

4386
if (!showCursor) {
4487
cliCursor.show();
@@ -49,7 +92,7 @@ const create = (stream: Writable, {showCursor = false} = {}): LogUpdate => {
4992
render.sync = (str: string) => {
5093
const output = str + '\n';
5194
previousOutput = output;
52-
previousLineCount = output.split('\n').length;
95+
previousLines = output.split('\n');
5396
};
5497

5598
return render;

test/log-update.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import test from 'ava';
2+
import ansiEscapes from 'ansi-escapes';
3+
import logUpdate from '../src/log-update.js';
4+
import createStdout from './helpers/create-stdout.js';
5+
6+
test('renders and updates output', t => {
7+
const stdout = createStdout();
8+
const render = logUpdate.create(stdout);
9+
10+
render('Hello');
11+
t.is((stdout.write as any).callCount, 1);
12+
t.is((stdout.write as any).firstCall.args[0], 'Hello\n');
13+
14+
render('World');
15+
t.is((stdout.write as any).callCount, 2);
16+
t.true(
17+
((stdout.write as any).secondCall.args[0] as string).includes('World'),
18+
);
19+
});
20+
21+
test('skips identical output', t => {
22+
const stdout = createStdout();
23+
const render = logUpdate.create(stdout);
24+
25+
render('Hello');
26+
render('Hello');
27+
28+
t.is((stdout.write as any).callCount, 1);
29+
});
30+
31+
test('incremental render (surgical updates)', t => {
32+
const stdout = createStdout();
33+
const render = logUpdate.create(stdout);
34+
35+
render('Line 1\nLine 2\nLine 3');
36+
render('Line 1\nUpdated\nLine 3');
37+
38+
const secondCall = (stdout.write as any).secondCall.args[0] as string;
39+
t.true(secondCall.includes(ansiEscapes.cursorNextLine)); // Skips unchanged lines
40+
t.true(secondCall.includes('Updated')); // Only updates changed line
41+
t.false(secondCall.includes('Line 1')); // Doesn't rewrite unchanged
42+
t.false(secondCall.includes('Line 3')); // Doesn't rewrite unchanged
43+
});
44+
45+
test('clears extra lines when output shrinks', t => {
46+
const stdout = createStdout();
47+
const render = logUpdate.create(stdout);
48+
49+
render('Line 1\nLine 2\nLine 3');
50+
render('Line 1');
51+
52+
const secondCall = (stdout.write as any).secondCall.args[0] as string;
53+
t.true(secondCall.includes(ansiEscapes.eraseLines(2))); // Erases 2 extra lines
54+
});
55+
56+
test('incremental render when output grows', t => {
57+
const stdout = createStdout();
58+
const render = logUpdate.create(stdout);
59+
60+
render('Line 1');
61+
render('Line 1\nLine 2\nLine 3');
62+
63+
const secondCall = (stdout.write as any).secondCall.args[0] as string;
64+
t.true(secondCall.includes(ansiEscapes.cursorNextLine)); // Skips unchanged first line
65+
t.true(secondCall.includes('Line 2')); // Adds new line
66+
t.true(secondCall.includes('Line 3')); // Adds new line
67+
t.false(secondCall.includes('Line 1')); // Doesn't rewrite unchanged
68+
});
69+
70+
test('single write call with multiple surgical updates', t => {
71+
const stdout = createStdout();
72+
const render = logUpdate.create(stdout);
73+
74+
render(
75+
'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10',
76+
);
77+
render(
78+
'Line 1\nUpdated 2\nLine 3\nUpdated 4\nLine 5\nUpdated 6\nLine 7\nUpdated 8\nLine 9\nUpdated 10',
79+
);
80+
81+
t.is((stdout.write as any).callCount, 2); // Only 2 writes total (initial + update)
82+
});

test/render.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,16 @@ test.serial('erase screen where state changes', async t => {
125125
const ps = term('erase-with-state-change', ['4']);
126126
await ps.waitForExit();
127127

128-
const secondFrame = ps.output.split(ansiEscapes.eraseLines(3))[1];
128+
const lastFrame = ps.output.split(ansiEscapes.cursorHide).at(-1);
129+
if (!lastFrame) {
130+
t.fail('lastFrame is undefined');
131+
return;
132+
}
133+
134+
const lastFrameContent = stripAnsi(lastFrame);
129135

130136
for (const letter of ['A', 'B', 'C']) {
131-
t.false(secondFrame?.includes(letter));
137+
t.false(lastFrameContent.includes(letter));
132138
}
133139
});
134140

0 commit comments

Comments
 (0)