Skip to content

Commit 2ab4f76

Browse files
committed
Reduce flicker in rendering
Fixes #226 Fixes #221
1 parent 8d17b13 commit 2ab4f76

File tree

2 files changed

+132
-45
lines changed

2 files changed

+132
-45
lines changed

index.js

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import stdinDiscarder from 'stdin-discarder';
1111

1212
// Constants
1313
const RENDER_DEFERRAL_TIMEOUT = 200; // Milliseconds to wait before re-rendering after partial chunk write
14+
const SYNCHRONIZED_OUTPUT_ENABLE = '\u001B[?2026h';
15+
const SYNCHRONIZED_OUTPUT_DISABLE = '\u001B[?2026l';
1416

1517
// Global state for concurrent spinner detection
1618
const activeHooksPerStream = new Map(); // Stream → ora instance
@@ -442,33 +444,47 @@ class Ora {
442444
return this;
443445
}
444446

445-
this.clear();
447+
const useSynchronizedOutput = this.#stream.isTTY;
448+
let shouldDisableSynchronizedOutput = false;
446449

447-
let frameContent = this.frame();
448-
const columns = this.#stream.columns ?? 80;
449-
const actualLineCount = this.#computeLineCountFrom(frameContent, columns);
450+
try {
451+
if (useSynchronizedOutput) {
452+
this.#internalWrite(() => this.#stream.write(SYNCHRONIZED_OUTPUT_ENABLE));
453+
shouldDisableSynchronizedOutput = true;
454+
}
450455

451-
// If content would exceed viewport height, truncate it to prevent garbage
452-
const consoleHeight = this.#stream.rows;
453-
if (consoleHeight && consoleHeight > 1 && actualLineCount > consoleHeight) {
454-
const lines = frameContent.split('\n');
455-
const maxLines = consoleHeight - 1; // Reserve one line for truncation message
456-
frameContent = [...lines.slice(0, maxLines), '... (content truncated to fit terminal)'].join('\n');
457-
}
456+
this.clear();
458457

459-
const canContinue = this.#internalWrite(() => this.#stream.write(frameContent));
458+
let frameContent = this.frame();
459+
const columns = this.#stream.columns ?? 80;
460+
const actualLineCount = this.#computeLineCountFrom(frameContent, columns);
460461

461-
// Handle backpressure - pause rendering if stream buffer is full
462-
if (canContinue === false && this.#stream.isTTY) {
463-
this.#drainHandler = () => {
464-
this.#drainHandler = undefined;
465-
this.#tryRender();
466-
};
462+
// If content would exceed viewport height, truncate it to prevent garbage
463+
const consoleHeight = this.#stream.rows;
464+
if (consoleHeight && consoleHeight > 1 && actualLineCount > consoleHeight) {
465+
const lines = frameContent.split('\n');
466+
const maxLines = consoleHeight - 1; // Reserve one line for truncation message
467+
frameContent = [...lines.slice(0, maxLines), '... (content truncated to fit terminal)'].join('\n');
468+
}
467469

468-
this.#stream.once('drain', this.#drainHandler);
469-
}
470+
const canContinue = this.#internalWrite(() => this.#stream.write(frameContent));
471+
472+
// Handle backpressure - pause rendering if stream buffer is full
473+
if (canContinue === false && this.#stream.isTTY) {
474+
this.#drainHandler = () => {
475+
this.#drainHandler = undefined;
476+
this.#tryRender();
477+
};
478+
479+
this.#stream.once('drain', this.#drainHandler);
480+
}
470481

471-
this.#linesToClear = this.#computeLineCountFrom(frameContent, columns);
482+
this.#linesToClear = this.#computeLineCountFrom(frameContent, columns);
483+
} finally {
484+
if (shouldDisableSynchronizedOutput) {
485+
this.#internalWrite(() => this.#stream.write(SYNCHRONIZED_OUTPUT_DISABLE));
486+
}
487+
}
472488

473489
return this;
474490
}

test.js

Lines changed: 95 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,46 @@ import TransformTTY from 'transform-tty';
99
import ora, {oraPromise, spinners} from './index.js';
1010

1111
const spinnerCharacter = process.platform === 'win32' ? '-' : '⠋';
12+
const synchronizedOutputEnable = '\u001B[?2026h';
13+
const synchronizedOutputDisable = '\u001B[?2026l';
1214
const noop = () => {};
1315

16+
const getLastSynchronizedOutput = output => {
17+
const lastEnableIndex = output.lastIndexOf(synchronizedOutputEnable);
18+
if (lastEnableIndex === -1) {
19+
return output;
20+
}
21+
22+
const disableIndex = output.indexOf(synchronizedOutputDisable, lastEnableIndex);
23+
if (disableIndex === -1) {
24+
return output.slice(lastEnableIndex + synchronizedOutputEnable.length);
25+
}
26+
27+
return output.slice(lastEnableIndex + synchronizedOutputEnable.length, disableIndex);
28+
};
29+
30+
const stripSynchronizedOutputSequences = content => {
31+
if (typeof content !== 'string') {
32+
return content;
33+
}
34+
35+
return content.replaceAll(synchronizedOutputEnable, '').replaceAll(synchronizedOutputDisable, '');
36+
};
37+
38+
const applySynchronizedOutputFilter = stream => {
39+
const originalWrite = stream.write;
40+
stream.write = function (content, encoding, callback) {
41+
const filteredContent = stripSynchronizedOutputSequences(content);
42+
if (filteredContent === '') {
43+
return true;
44+
}
45+
46+
return originalWrite.call(this, filteredContent, encoding, callback);
47+
};
48+
49+
return stream;
50+
};
51+
1452
const getPassThroughStream = () => {
1553
const stream = new PassThroughStream();
1654
stream.clearLine = noop;
@@ -75,6 +113,33 @@ test('main', async () => {
75113
assert.match(result, new RegExp(`${spinnerCharacter} foo`));
76114
});
77115

116+
test('render uses synchronized output sequences', async () => {
117+
const stream = getPassThroughStream();
118+
stream.isTTY = true;
119+
const output = getStream(stream);
120+
121+
const spinner = ora({
122+
stream,
123+
text: 'foo',
124+
color: false,
125+
isEnabled: true,
126+
});
127+
128+
spinner.render();
129+
stream.end();
130+
131+
const result = await output;
132+
assert.ok(result.includes(synchronizedOutputEnable));
133+
assert.ok(result.includes(synchronizedOutputDisable));
134+
assert.ok(result.indexOf(synchronizedOutputEnable) < result.indexOf(synchronizedOutputDisable));
135+
136+
const synchronizedOutput = getLastSynchronizedOutput(result);
137+
const renderedText = stripVTControlCharacters(synchronizedOutput);
138+
assert.ok(renderedText.includes(spinnerCharacter));
139+
assert.ok(renderedText.includes('foo'));
140+
assert.strictEqual(stripSynchronizedOutputSequences(result), synchronizedOutput);
141+
});
142+
78143
test('`.id` is not set when created', () => {
79144
const spinner = ora('foo');
80145
assert.ok(!spinner.isSpinning);
@@ -741,7 +806,7 @@ test('multiline content that exactly fits console height is not truncated', () =
741806
let written = '';
742807
const originalWrite = stream.write;
743808
stream.write = function (content) {
744-
written = content;
809+
written += String(content);
745810
return originalWrite.call(this, content);
746811
};
747812

@@ -755,8 +820,9 @@ test('multiline content that exactly fits console height is not truncated', () =
755820
spinner.start();
756821
spinner.render();
757822

758-
assert.ok(written.includes('Line 3'));
759-
assert.ok(!written.includes('(content truncated to fit terminal)'));
823+
const renderedOutput = stripVTControlCharacters(getLastSynchronizedOutput(written));
824+
assert.ok(renderedOutput.includes('Line 3'));
825+
assert.ok(!renderedOutput.includes('(content truncated to fit terminal)'));
760826

761827
spinner.stop();
762828
});
@@ -840,7 +906,7 @@ const currentClearMethod = transFormTTY => {
840906
};
841907

842908
test('new clear method test, basic', () => {
843-
const transformTTY = new TransformTTY({crlf: true});
909+
const transformTTY = applySynchronizedOutputFilter(new TransformTTY({crlf: true}));
844910
transformTTY.addSequencer();
845911
transformTTY.addSequencer(null, true);
846912

@@ -849,7 +915,7 @@ test('new clear method test, basic', () => {
849915
it means the `spinner.clear()` method has failed to fully clear output between calls to render.
850916
*/
851917

852-
const currentClearTTY = new TransformTTY({crlf: true});
918+
const currentClearTTY = applySynchronizedOutputFilter(new TransformTTY({crlf: true}));
853919
currentClearTTY.addSequencer();
854920

855921
const currentOra = currentClearMethod(currentClearTTY);
@@ -909,11 +975,11 @@ test('new clear method test, basic', () => {
909975
});
910976

911977
test('new clear method test, erases wrapped lines', () => {
912-
const transformTTY = new TransformTTY({crlf: true, columns: 40});
978+
const transformTTY = applySynchronizedOutputFilter(new TransformTTY({crlf: true, columns: 40}));
913979
transformTTY.addSequencer();
914980
transformTTY.addSequencer(null, true);
915981

916-
const currentClearTTY = new TransformTTY({crlf: true, columns: 40});
982+
const currentClearTTY = applySynchronizedOutputFilter(new TransformTTY({crlf: true, columns: 40}));
917983
currentClearTTY.addSequencer();
918984

919985
const currentOra = currentClearMethod(currentClearTTY);
@@ -1073,11 +1139,11 @@ test('new clear method, stress test', () => {
10731139
s2.indent = indent;
10741140
};
10751141

1076-
const transformTTY = new TransformTTY({crlf: true});
1142+
const transformTTY = applySynchronizedOutputFilter(new TransformTTY({crlf: true}));
10771143
transformTTY.addSequencer();
10781144
transformTTY.addSequencer(null, true);
10791145

1080-
const currentClearTTY = new TransformTTY({crlf: true});
1146+
const currentClearTTY = applySynchronizedOutputFilter(new TransformTTY({crlf: true}));
10811147
currentClearTTY.addSequencer();
10821148

10831149
const currentOra = currentClearMethod(currentClearTTY);
@@ -1354,7 +1420,7 @@ test('multiline text exceeding console height', () => {
13541420
// Override write to capture content
13551421
const originalWrite = stream.write;
13561422
stream.write = function (content) {
1357-
writtenContent = content;
1423+
writtenContent += String(content);
13581424
return originalWrite.call(this, content);
13591425
};
13601426

@@ -1368,12 +1434,14 @@ test('multiline text exceeding console height', () => {
13681434
spinner.start();
13691435
spinner.render(); // Force a render
13701436

1437+
const renderedOutput = stripVTControlCharacters(getLastSynchronizedOutput(writtenContent));
1438+
13711439
// When content exceeds viewport, should truncate with message
1372-
assert.ok(writtenContent.includes('Line 1'), 'Should include some original content');
1373-
assert.ok(writtenContent.includes('(content truncated to fit terminal)'), 'Should show truncation message');
1440+
assert.ok(renderedOutput.includes('Line 1'), 'Should include some original content');
1441+
assert.ok(renderedOutput.includes('(content truncated to fit terminal)'), 'Should show truncation message');
13741442

13751443
// Should not include all 10 lines
1376-
const lineCount = (writtenContent.match(/Line \d+/g) || []).length;
1444+
const lineCount = (renderedOutput.match(/Line \d+/g) || []).length;
13771445
assert.ok(lineCount < 10, 'Should truncate some lines');
13781446
assert.ok(lineCount <= 5, 'Should not exceed terminal height');
13791447

@@ -1392,7 +1460,7 @@ test('multiline text within console height (no truncation)', () => {
13921460
// Override write to capture content
13931461
const originalWrite = stream.write;
13941462
stream.write = function (content) {
1395-
writtenContent = content;
1463+
writtenContent += String(content);
13961464
return originalWrite.call(this, content);
13971465
};
13981466

@@ -1407,9 +1475,10 @@ test('multiline text within console height (no truncation)', () => {
14071475
spinner.render();
14081476

14091477
// When content is within viewport, should not truncate
1410-
assert.ok(writtenContent.includes('Line 1'), 'Should include first line');
1411-
assert.ok(writtenContent.includes('Line 5'), 'Should include last line');
1412-
assert.ok(!writtenContent.includes('(content truncated to fit terminal)'), 'Should not show truncation message');
1478+
const renderedOutput = stripVTControlCharacters(getLastSynchronizedOutput(writtenContent));
1479+
assert.ok(renderedOutput.includes('Line 1'), 'Should include first line');
1480+
assert.ok(renderedOutput.includes('Line 5'), 'Should include last line');
1481+
assert.ok(!renderedOutput.includes('(content truncated to fit terminal)'), 'Should not show truncation message');
14131482

14141483
spinner.stop();
14151484
});
@@ -1426,7 +1495,7 @@ test('multiline text with undefined terminal rows (no truncation)', () => {
14261495
// Override write to capture content
14271496
const originalWrite = stream.write;
14281497
stream.write = function (content) {
1429-
writtenContent = content;
1498+
writtenContent += String(content);
14301499
return originalWrite.call(this, content);
14311500
};
14321501

@@ -1441,9 +1510,10 @@ test('multiline text with undefined terminal rows (no truncation)', () => {
14411510
spinner.render();
14421511

14431512
// When terminal height is unknown, should not truncate (no truncation applied)
1444-
assert.ok(writtenContent.includes('Line 1'), 'Should include first line');
1445-
assert.ok(writtenContent.includes('Line 10'), 'Should include last line');
1446-
assert.ok(!writtenContent.includes('(content truncated to fit terminal)'), 'Should not truncate when height is unknown');
1513+
const renderedOutput = stripVTControlCharacters(getLastSynchronizedOutput(writtenContent));
1514+
assert.ok(renderedOutput.includes('Line 1'), 'Should include first line');
1515+
assert.ok(renderedOutput.includes('Line 10'), 'Should include last line');
1516+
assert.ok(!renderedOutput.includes('(content truncated to fit terminal)'), 'Should not truncate when height is unknown');
14471517

14481518
spinner.stop();
14491519
});
@@ -1458,7 +1528,7 @@ test('multiline text with very small console height (no truncation)', () => {
14581528
let writtenContent = '';
14591529
const originalWrite = stream.write;
14601530
stream.write = function (content) {
1461-
writtenContent = content;
1531+
writtenContent += String(content);
14621532
return originalWrite.call(this, content);
14631533
};
14641534

@@ -1473,8 +1543,9 @@ test('multiline text with very small console height (no truncation)', () => {
14731543
spinner.render();
14741544

14751545
// When console is too small (1 row), should not truncate because no room for message
1476-
assert.ok(writtenContent.includes('Line 1'), 'Should include content');
1477-
assert.ok(!writtenContent.includes('(content truncated to fit terminal)'), 'Should not truncate when console too small for message');
1546+
const renderedOutput = stripVTControlCharacters(getLastSynchronizedOutput(writtenContent));
1547+
assert.ok(renderedOutput.includes('Line 1'), 'Should include content');
1548+
assert.ok(!renderedOutput.includes('(content truncated to fit terminal)'), 'Should not truncate when console too small for message');
14781549

14791550
spinner.stop();
14801551
});

0 commit comments

Comments
 (0)