Skip to content

Commit 4a0ae7c

Browse files
committed
feat(render): Replace perpetual rAF loop with event-driven render scheduler
startRenderLoop kept a CPU core hot at ~60 Hz forever. Even on a static screen each frame paid for a render() entry/exit (which calls into WASM via update() and clearDirty()) and a getCursor() round-trip into WASM. Browser tabs hosting an idle terminal pinned a core for as long as the tab was open. Replace the unconditional loop with requestRender(): an idempotent single-rAF scheduler that's a no-op if a frame is already pending. Wake points are placed on every event source that mutates renderable state — writes from the PTY (writeInternal), each smooth-scroll animateScroll tick, scroll API mutations (scrollLines, scrollToTop, scrollToBottom, scrollToLine, smoothScrollTo immediate-jump), selection changes, post-resize, and the cursor-blink interval (via a new onRequestRender callback the renderer holds and the Terminal sets). The renderer's setHoveredHyperlinkId / setHoveredLinkRange also wake on actual state change, with identity dedupe. After open()'s forced render, run one synchronous renderTick to mirror the prior loop's first iteration: refreshRowMetaCache (used by isRowWrapped) walks the WASM row iterator immediately after open() and depends on the second update / clearDirty pair to settle WASM state. Without this, the existing isRowWrapped test fails. End state: idle terminal does zero JS work and zero WASM calls until the next event. An equivalent design — fully event-driven with no loop at all, where every state mutation calls requestRender directly — would land in the same place; we kept the requestRender shape for surgical scope. Signed-off-by: Evan Wies <evan@neomantra.net>
1 parent 0822d6e commit 4a0ae7c

2 files changed

Lines changed: 107 additions & 33 deletions

File tree

lib/renderer.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ export class CanvasRenderer {
149149
private cursorBlinkInterval?: number;
150150
private lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 };
151151

152+
// Hook called whenever the renderer's own internal state (today: cursor
153+
// blink toggle) changes such that the next frame would look different.
154+
// Set by Terminal so it can wake its render scheduler. Without this, an
155+
// event-driven Terminal that has gone idle would never repaint the
156+
// blinking cursor.
157+
private onRequestRender: (() => void) | null = null;
158+
152159
// Viewport tracking (for scrolling)
153160
private lastViewportY: number = 0;
154161

@@ -1234,11 +1241,23 @@ export class CanvasRenderer {
12341241
// Cursor Blinking
12351242
// ==========================================================================
12361243

1244+
/**
1245+
* Set a callback the renderer invokes when its internal state changes
1246+
* outside the normal render-driven path (today: cursor-blink toggles).
1247+
* Lets an event-driven Terminal wake its render scheduler instead of
1248+
* polling every frame to catch the blink flip.
1249+
*/
1250+
public setOnRequestRender(fn: (() => void) | null): void {
1251+
this.onRequestRender = fn;
1252+
}
1253+
12371254
private startCursorBlink(): void {
12381255
// xterm.js uses ~530ms blink interval
12391256
this.cursorBlinkInterval = window.setInterval(() => {
12401257
this.cursorVisible = !this.cursorVisible;
1241-
// Note: Render loop should redraw cursor line automatically
1258+
// Wake the render scheduler so the cursor cell is actually
1259+
// repainted with the new visibility state.
1260+
this.onRequestRender?.();
12421261
}, 530);
12431262
}
12441263

@@ -1420,7 +1439,9 @@ export class CanvasRenderer {
14201439
* Set the currently hovered hyperlink ID for rendering underlines
14211440
*/
14221441
public setHoveredHyperlinkId(hyperlinkId: number): void {
1442+
if (this.hoveredHyperlinkId === hyperlinkId) return;
14231443
this.hoveredHyperlinkId = hyperlinkId;
1444+
this.onRequestRender?.();
14241445
}
14251446

14261447
/**
@@ -1435,7 +1456,12 @@ export class CanvasRenderer {
14351456
endY: number;
14361457
} | null
14371458
): void {
1459+
// Coarse change check — link-detection is rate-limited upstream and
1460+
// these setters are only called on hover transitions, so identity
1461+
// comparison is enough to dedupe back-to-back clears.
1462+
if (this.hoveredLinkRange === range) return;
14381463
this.hoveredLinkRange = range;
1464+
this.onRequestRender?.();
14391465
}
14401466

14411467
/**

lib/terminal.ts

Lines changed: 80 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,8 @@ export class Terminal implements ITerminalCore {
502502
// Forward selection change events
503503
this.selectionManager.onSelectionChange(() => {
504504
this.selectionChangeEmitter.fire();
505+
// Selection rows need to repaint with the highlight overlay.
506+
this.requestRender();
505507
});
506508

507509
// Initialize link detection system
@@ -530,8 +532,16 @@ export class Terminal implements ITerminalCore {
530532
// Render initial blank screen (force full redraw)
531533
this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
532534

533-
// Start render loop
534-
this.startRenderLoop();
535+
// Wire the renderer back to the render scheduler so internal
536+
// state changes (cursor blink) wake the loop on demand.
537+
this.renderer.setOnRequestRender(() => this.requestRender());
538+
539+
// Run one synchronous render+cursor-poll to mirror the prior
540+
// loop's first iteration. Some downstream callers
541+
// (notably refreshRowMetaCache for isRowWrapped) walk the WASM
542+
// row iterator immediately after open() and rely on the second
543+
// update() / clearDirty pair to settle WASM state.
544+
this.renderTick();
535545

536546
// Focus input (auto-focus so user can start typing immediately)
537547
this.focus();
@@ -600,7 +610,9 @@ export class Terminal implements ITerminalCore {
600610
requestAnimationFrame(callback);
601611
}
602612

603-
// Render will happen on next animation frame
613+
// Wake the render scheduler — the write almost certainly mutated
614+
// visible state. Idempotent if a render is already pending.
615+
this.requestRender();
604616
}
605617

606618
/**
@@ -711,9 +723,10 @@ export class Terminal implements ITerminalCore {
711723
console.error('Terminal resize failed:', e);
712724
}
713725

714-
// Flush any writes that were queued during resize, then restart render loop
726+
// Flush any writes that were queued during resize, then schedule a
727+
// render to pick up the new dimensions / flushed writes.
715728
this.flushWriteQueue();
716-
this.startRenderLoop();
729+
this.requestRender();
717730
}
718731

719732
/**
@@ -939,6 +952,8 @@ export class Terminal implements ITerminalCore {
939952
if (scrollbackLength > 0) {
940953
this.showScrollbar();
941954
}
955+
956+
this.requestRender();
942957
}
943958
}
944959

@@ -959,6 +974,7 @@ export class Terminal implements ITerminalCore {
959974
this.viewportY = scrollbackLength;
960975
this.scrollEmitter.fire(this.viewportY);
961976
this.showScrollbar();
977+
this.requestRender();
962978
}
963979
}
964980

@@ -973,6 +989,7 @@ export class Terminal implements ITerminalCore {
973989
if (this.getScrollbackLength() > 0) {
974990
this.showScrollbar();
975991
}
992+
this.requestRender();
976993
}
977994
}
978995

@@ -992,6 +1009,8 @@ export class Terminal implements ITerminalCore {
9921009
if (scrollbackLength > 0) {
9931010
this.showScrollbar();
9941011
}
1012+
1013+
this.requestRender();
9951014
}
9961015
}
9971016

@@ -1018,6 +1037,7 @@ export class Terminal implements ITerminalCore {
10181037
if (scrollbackLength > 0) {
10191038
this.showScrollbar();
10201039
}
1040+
this.requestRender();
10211041
return;
10221042
}
10231043

@@ -1066,6 +1086,8 @@ export class Terminal implements ITerminalCore {
10661086
this.scrollAnimationFrame = undefined;
10671087
this.scrollAnimationStartTime = undefined;
10681088
this.scrollAnimationStartY = undefined;
1089+
// Final-position render
1090+
this.requestRender();
10691091
return;
10701092
}
10711093

@@ -1086,6 +1108,11 @@ export class Terminal implements ITerminalCore {
10861108
this.showScrollbar();
10871109
}
10881110

1111+
// Each tick mutates viewportY, so the main render path needs to
1112+
// catch up. The animateScroll rAF below only advances the scroll
1113+
// state; rendering is the renderTick's job.
1114+
this.requestRender();
1115+
10891116
// Continue animation
10901117
this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll);
10911118
};
@@ -1185,36 +1212,57 @@ export class Terminal implements ITerminalCore {
11851212
}
11861213

11871214
/**
1188-
* Start the render loop
1215+
* Schedule a single render on the next animation frame. No-op if one
1216+
* is already pending or the terminal is closed/disposed.
1217+
*
1218+
* Replaces the previous perpetual rAF chain, which kept a CPU core
1219+
* hot at ~60Hz even on a static screen because every frame paid for a
1220+
* render() entry/exit and a getCursor() round-trip into WASM. With
1221+
* this design, the terminal goes idle (zero JS work, zero WASM calls)
1222+
* once the last event-driven render is done, until the next event
1223+
* wakes it via requestRender().
1224+
*
1225+
* Wake points are added on every event source that mutates renderable
1226+
* state: writes from the PTY, scrolls, resizes, mouse motion (link
1227+
* hover), selection changes, the cursor-blink interval (via the
1228+
* renderer's onRequestRender callback), and each smooth-scroll tick.
1229+
*
1230+
* Alternative design we considered: leave the rAF chain in place but
1231+
* have it short-circuit when no work is pending and self-cancel after
1232+
* N idle frames, with the same wake points re-arming it. End-state
1233+
* CPU is identical; the difference is purely code shape (a perpetual
1234+
* loop with self-cancel logic vs. ad-hoc rAF scheduling). We picked
1235+
* this shape for simplicity.
11891236
*/
1190-
private startRenderLoop(): void {
1191-
if (this.animationFrameId) return; // already running
1192-
const loop = () => {
1193-
if (!this.isDisposed && this.isOpen) {
1194-
// Render using WASM's native dirty tracking
1195-
// The render() method:
1196-
// 1. Calls update() once to sync state and check dirty flags
1197-
// 2. Only redraws dirty rows when forceAll=false
1198-
// 3. Always calls clearDirty() at the end
1199-
this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity);
1200-
1201-
// Check for cursor movement (Phase 2: onCursorMove event)
1202-
// Note: getCursor() reads from already-updated render state (from render() above)
1203-
const cursor = this.wasmTerm!.getCursor();
1204-
if (cursor.y !== this.lastCursorY) {
1205-
this.lastCursorY = cursor.y;
1206-
this.cursorMoveEmitter.fire();
1207-
}
1237+
private requestRender(): void {
1238+
if (this.animationFrameId !== undefined) return;
1239+
if (this.isDisposed || !this.isOpen) return;
1240+
this.animationFrameId = requestAnimationFrame(this.renderTick);
1241+
}
12081242

1209-
// Note: onRender event is intentionally not fired in the render loop
1210-
// to avoid performance issues. For now, consumers can use requestAnimationFrame
1211-
// if they need frame-by-frame updates.
1243+
private renderTick = (): void => {
1244+
this.animationFrameId = undefined;
1245+
if (this.isDisposed || !this.isOpen) return;
12121246

1213-
this.animationFrameId = requestAnimationFrame(loop);
1214-
}
1215-
};
1216-
loop();
1217-
}
1247+
// Render using WASM's native dirty tracking
1248+
// The render() method:
1249+
// 1. Calls update() once to sync state and check dirty flags
1250+
// 2. Only redraws dirty rows when forceAll=false
1251+
// 3. Always calls clearDirty() at the end
1252+
this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity);
1253+
1254+
// Check for cursor movement (Phase 2: onCursorMove event)
1255+
// Note: getCursor() reads from already-updated render state (from render() above)
1256+
const cursor = this.wasmTerm!.getCursor();
1257+
if (cursor.y !== this.lastCursorY) {
1258+
this.lastCursorY = cursor.y;
1259+
this.cursorMoveEmitter.fire();
1260+
}
1261+
1262+
// Note: onRender event is intentionally not fired here to avoid
1263+
// performance issues. Consumers can use requestAnimationFrame if
1264+
// they need frame-by-frame updates.
1265+
};
12181266

12191267
/**
12201268
* Get a line from native WASM scrollback buffer

0 commit comments

Comments
 (0)