feat: add patchConsole, waitUntilRenderFlush, onRender, and maxFps

Add four behavioral surfaces to the runtime:

- patchConsole (default: true): intercepts console.* methods to route
  output through writeToStdout/writeToStderr, preventing frame corruption.
  Disabled in debug mode. Restored before Vue cleanup on teardown.
- waitUntilRenderFlush(): flushes pending throttled renders and waits
  for the stdout write barrier, exposed on TuiApp and testing RenderResult.
- onRender callback: fires after each commit with { renderTime } in ms.
- maxFps option: overrides the scheduler's default 32ms throttle interval
  with 1000/maxFps. The scheduler now accepts a throttleMs option and
  exposes hasPending() for flush coordination.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 15:54:18 +08:00
parent dee060bb76
commit 3ee01dc278
10 changed files with 299 additions and 11 deletions
@@ -0,0 +1,94 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { createApp, Text } from "@vue-tui/runtime";
import { makeFakeStdin, makeFakeWritable } from "./test-streams.ts";
test("onRender callback is called with renderTime on each commit", async () => {
const renderTimes: number[] = [];
const App = defineComponent(() => () => <Text>hello</Text>);
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({
stdout,
stdin,
stderr,
debug: true,
exitOnCtrlC: false,
onRender: (info) => {
renderTimes.push(info.renderTime);
},
});
await nextTick();
await nextTick();
expect(renderTimes.length).toBeGreaterThanOrEqual(1);
expect(renderTimes[0]).toBeTypeOf("number");
expect(renderTimes[0]).toBeGreaterThanOrEqual(0);
app.unmount();
});
test("onRender is called on subsequent state updates", async () => {
const renderTimes: number[] = [];
const msg = shallowRef("a");
const App = defineComponent(() => {
return () => <Text>{msg.value}</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({
stdout,
stdin,
stderr,
debug: true,
exitOnCtrlC: false,
onRender: (info) => {
renderTimes.push(info.renderTime);
},
});
await nextTick();
await nextTick();
const initialCount = renderTimes.length;
msg.value = "b";
await nextTick();
await nextTick();
expect(renderTimes.length).toBeGreaterThan(initialCount);
app.unmount();
});
test("no onRender callback when option is not provided", async () => {
// Just verify the app works fine without onRender
const App = defineComponent(() => () => <Text>no callback</Text>);
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({
stdout,
stdin,
stderr,
debug: true,
exitOnCtrlC: false,
});
await nextTick();
await nextTick();
app.unmount();
});
@@ -0,0 +1,21 @@
import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text } from "@vue-tui/runtime";
test("patchConsole is disabled in debug mode (testing render uses debug)", async () => {
// The testing render() helper uses debug: true, which auto-disables
// patchConsole. Verify that the app still renders correctly without it.
const App = defineComponent(() => () => <Text>UI</Text>);
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("UI");
});
test("patchConsole option defaults to true and can be set to false", async () => {
// This test just verifies the option is accepted without throwing.
// Since testing uses debug mode, patchConsole is a no-op regardless,
// but the option path must not error.
const App = defineComponent(() => () => <Text>hello</Text>);
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("hello");
});
@@ -0,0 +1,30 @@
import { PassThrough } from "node:stream";
export interface FakeWritableOptions {
columns?: number;
rows?: number;
}
export function makeFakeWritable(options: FakeWritableOptions = {}): NodeJS.WriteStream {
const s = new PassThrough() as unknown as NodeJS.WriteStream;
Object.assign(s, {
columns: options.columns ?? 100,
rows: options.rows ?? 100,
isTTY: true,
});
return s;
}
export function makeFakeStdin(): { stream: NodeJS.ReadStream } {
const s = new PassThrough() as unknown as NodeJS.ReadStream;
Object.assign(s, {
isTTY: true,
setRawMode(this: NodeJS.ReadStream) {
return this;
},
setEncoding(this: NodeJS.ReadStream) {
return this;
},
});
return { stream: s };
}
@@ -0,0 +1,36 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text } from "@vue-tui/runtime";
test("waitUntilRenderFlush resolves after frame is written", async () => {
const App = defineComponent(() => () => <Text>hello</Text>);
const result = await render(App);
await result.waitUntilRenderFlush();
expect(result.lastFrame()).toContain("hello");
});
test("waitUntilRenderFlush waits for pending state updates", async () => {
const msg = shallowRef("before");
const App = defineComponent(() => {
return () => <Text>{msg.value}</Text>;
});
const result = await render(App);
expect(result.lastFrame()).toContain("before");
msg.value = "after";
await nextTick();
await nextTick();
await result.waitUntilRenderFlush();
expect(result.lastFrame()).toContain("after");
});
test("waitUntilRenderFlush can be called multiple times", async () => {
const App = defineComponent(() => () => <Text>stable</Text>);
const result = await render(App);
await result.waitUntilRenderFlush();
await result.waitUntilRenderFlush();
expect(result.lastFrame()).toContain("stable");
});
+1
View File
@@ -29,6 +29,7 @@
"cli-boxes": "^3.0.0",
"is-in-ci": "catalog:",
"log-update": "^6.0.0",
"patch-console": "catalog:",
"slice-ansi": "^7.1.0",
"string-width": "^7.2.0",
"wrap-ansi": "^9.0.0",
+83 -1
View File
@@ -12,6 +12,7 @@ import {
import { createRenderer } from "@vue/runtime-core";
import { EventEmitter } from "node:events";
import isInCi from "is-in-ci";
import patchConsoleFn from "patch-console";
import { createInputParser, type InputEvent } from "./io/input-parser.ts";
import { createRoot, type TuiRoot, type TuiNode } from "./host/nodes.ts";
import { attachYoga, detachYoga } from "./host/yoga.ts";
@@ -53,11 +54,33 @@ export interface MountOptions {
* @default true (false if in CI or `stdout.isTTY` is falsy)
*/
interactive?: boolean;
/**
* Patch `console.*` methods to route output through the TUI frame
* coordinator (writeToStdout / writeToStderr) so that console.log
* calls don't corrupt the rendered UI.
*
* Automatically disabled in debug mode.
*
* @default true
*/
patchConsole?: boolean;
/**
* Callback invoked after each render commit with timing information.
*/
onRender?: (info: { renderTime: number }) => void;
/**
* Maximum frames per second. Controls the throttle interval used by the
* commit scheduler. When not set, the default ~30fps (32ms) is used.
*
* Ignored in debug mode (commits are immediate).
*/
maxFps?: number;
}
export interface TuiApp extends Omit<VueApp<TuiNode>, "mount"> {
mount(options?: MountOptions): ComponentPublicInstance;
waitUntilExit(): Promise<unknown>;
waitUntilRenderFlush(): Promise<void>;
}
type RootProps = Record<string, unknown>;
@@ -87,6 +110,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
let mountedInteractive = true;
let mountedRawMode = false;
let mountedGetLastOutput: (() => string) | null = null;
let mountedRestoreConsole: (() => void) | null = null;
let mountedScheduler: ReturnType<typeof createCommitScheduler> | null = null;
// The renderer's onCommit closure is wired at createApp time but only does
// real work after mount swaps in scheduler.schedule. One renderer per app
@@ -98,6 +123,11 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
if (teardownStarted) return;
teardownStarted = true;
scheduledCommit = () => {};
// Restore console BEFORE Vue cleanup (matching Ink ink.tsx:779)
if (mountedRestoreConsole) {
mountedRestoreConsole();
mountedRestoreConsole = null;
}
try {
originalUnmount();
} catch {
@@ -183,6 +213,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
const debug = options.debug ?? false;
const exitOnCtrlC = options.exitOnCtrlC ?? true;
const rawMode = options.rawMode ?? true;
const onRender = options.onRender;
const maxFps = options.maxFps;
mountedDebug = debug;
// Interactive mode detection — matches Ink's logic:
@@ -307,6 +339,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
mountedWriter = writer;
function commit() {
const start = onRender ? performance.now() : 0;
// Detect <Static> identity changes (mount, unmount, key-driven remount).
// Fire onStaticChange BEFORE flushing static output so accumulated
// fullStaticOutput from a previous instance is cleared first.
@@ -338,6 +372,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
const frame = paint(tuiRoot);
frameState.lastOutput = frame;
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
if (onRender) onRender({ renderTime: performance.now() - start });
return;
}
@@ -353,9 +388,15 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
writer.write(frame);
if (onRender) onRender({ renderTime: performance.now() - start });
}
const scheduler = createCommitScheduler(commit, { immediate: debug });
const schedulerOptions: { immediate: boolean; throttleMs?: number } = { immediate: debug };
if (maxFps != null && !debug) {
schedulerOptions.throttleMs = Math.round(1000 / maxFps);
}
const scheduler = createCommitScheduler(commit, schedulerOptions);
mountedScheduler = scheduler;
scheduledCommit = scheduler.schedule;
// Internal provides — set before the actual mount so components can inject
@@ -406,6 +447,28 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
process.on("exit", exitListener);
mountedExitListener = exitListener;
// Patch console.log/warn/error etc. to route through writeToStdout /
// writeToStderr so console output doesn't corrupt the rendered frame.
// Disabled in debug mode (matching Ink).
if (options.patchConsole !== false && !debug) {
try {
mountedRestoreConsole = patchConsoleFn((stream, data) => {
if (stream === "stdout") {
appContext.writeToStdout(data);
}
if (stream === "stderr") {
// Filter Vue internal warnings
if (!data.startsWith("[Vue warn]")) {
appContext.writeToStderr(data);
}
}
});
} catch {
// patch-console uses console.Console which may not be available in
// some environments (e.g., vitest workers). Degrade gracefully.
}
}
return proxy;
};
@@ -418,6 +481,25 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
return exitPromise;
};
app.waitUntilRenderFlush = async function waitUntilRenderFlush(): Promise<void> {
// Flush any pending throttled render
if (mountedScheduler?.hasPending()) {
await mountedScheduler.flush();
}
// Wait for stdout write barrier — ensures the written frame is
// flushed to the underlying stream.
const stream = mountedAppContext?.stdout ?? process.stdout;
await new Promise<void>((resolve) => {
// PassThrough (test fakes) may not support the write callback form;
// fall back to setImmediate so we still yield the event loop.
try {
stream.write("", () => resolve());
} catch {
setImmediate(resolve);
}
});
};
return app;
}
+19 -10
View File
@@ -3,21 +3,26 @@ import { queuePostFlushCb } from "@vue/runtime-core";
export interface CommitScheduler {
schedule: () => void;
flush: () => Promise<void>;
/** Returns true when a trailing-edge commit is pending. */
hasPending: () => boolean;
}
export interface CommitSchedulerOptions {
/** Disable time-based throttle (used in tests / debug mode). */
immediate?: boolean;
/** Override throttle interval in ms. Takes precedence over the default 32ms. */
throttleMs?: number;
}
/** Minimum interval between commits in production (~30fps). */
const THROTTLE_MS = 32;
/** Default minimum interval between commits (~30fps). */
const DEFAULT_THROTTLE_MS = 32;
export function createCommitScheduler(
commit: () => void,
options: CommitSchedulerOptions = {},
): CommitScheduler {
const immediate = options.immediate ?? false;
const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
let scheduled = false;
let resolveFlush: (() => void) | null = null;
@@ -26,11 +31,11 @@ export function createCommitScheduler(
// are collapsed into a single trailing call at the end of the window.
let lastCommitTime = 0;
let trailingTimer: ReturnType<typeof setTimeout> | null = null;
let hasPending = false;
let hasPendingFlag = false;
function doCommit() {
scheduled = false;
hasPending = false;
hasPendingFlag = false;
lastCommitTime = Date.now();
try {
commit();
@@ -53,7 +58,7 @@ export function createCommitScheduler(
// passed since last commit (leading edge). Otherwise mark pending
// and let the trailing timer handle it.
const elapsed = Date.now() - lastCommitTime;
if (elapsed >= THROTTLE_MS) {
if (elapsed >= throttleMs) {
// Leading edge: fire immediately
if (trailingTimer) {
clearTimeout(trailingTimer);
@@ -62,23 +67,27 @@ export function createCommitScheduler(
doCommit();
} else {
// Within throttle window: schedule trailing edge
hasPending = true;
hasPendingFlag = true;
if (!trailingTimer) {
trailingTimer = setTimeout(() => {
trailingTimer = null;
if (hasPending) doCommit();
}, THROTTLE_MS - elapsed);
if (hasPendingFlag) doCommit();
}, throttleMs - elapsed);
}
}
});
}
function flush(): Promise<void> {
if (!scheduled && !hasPending) return Promise.resolve();
if (!scheduled && !hasPendingFlag) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveFlush = resolve;
});
}
return { schedule, flush };
function hasPending(): boolean {
return hasPendingFlag;
}
return { schedule, flush, hasPending };
}
+2
View File
@@ -32,6 +32,7 @@ export interface RenderResult {
terminal: Terminal;
unmount(this: void): void;
waitUntilExit(this: void): Promise<unknown>;
waitUntilRenderFlush(this: void): Promise<void>;
}
function trimFrame(raw: string): string {
@@ -132,5 +133,6 @@ export async function render(
terminal,
unmount: app.unmount.bind(app),
waitUntilExit: app.waitUntilExit.bind(app),
waitUntilRenderFlush: app.waitUntilRenderFlush.bind(app),
};
}