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:
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user