diff --git a/packages/runtime-tests/integration/lifecycle/on-render.test.tsx b/packages/runtime-tests/integration/lifecycle/on-render.test.tsx
new file mode 100644
index 0000000..e60d4ae
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/on-render.test.tsx
@@ -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(() => () => hello);
+
+ 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 () => {msg.value};
+ });
+
+ 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(() => () => no callback);
+
+ 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();
+});
diff --git a/packages/runtime-tests/integration/lifecycle/patch-console.test.tsx b/packages/runtime-tests/integration/lifecycle/patch-console.test.tsx
new file mode 100644
index 0000000..a2f9e96
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/patch-console.test.tsx
@@ -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(() => () => UI);
+ 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(() => () => hello);
+ const { lastFrame } = await render(App);
+ expect(lastFrame()).toContain("hello");
+});
diff --git a/packages/runtime-tests/integration/lifecycle/test-streams.ts b/packages/runtime-tests/integration/lifecycle/test-streams.ts
new file mode 100644
index 0000000..8679375
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/test-streams.ts
@@ -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 };
+}
diff --git a/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx b/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx
new file mode 100644
index 0000000..95ee7c7
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx
@@ -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(() => () => hello);
+ 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 () => {msg.value};
+ });
+
+ 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(() => () => stable);
+ const result = await render(App);
+
+ await result.waitUntilRenderFlush();
+ await result.waitUntilRenderFlush();
+ expect(result.lastFrame()).toContain("stable");
+});
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
index b2f227e..f009706 100644
--- a/packages/runtime/package.json
+++ b/packages/runtime/package.json
@@ -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",
diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts
index d5d37ab..7f9287b 100644
--- a/packages/runtime/src/render.ts
+++ b/packages/runtime/src/render.ts
@@ -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, "mount"> {
mount(options?: MountOptions): ComponentPublicInstance;
waitUntilExit(): Promise;
+ waitUntilRenderFlush(): Promise;
}
type RootProps = Record;
@@ -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 | 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 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 {
+ // 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((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;
}
diff --git a/packages/runtime/src/scheduler.ts b/packages/runtime/src/scheduler.ts
index 4efb034..1bb4b34 100644
--- a/packages/runtime/src/scheduler.ts
+++ b/packages/runtime/src/scheduler.ts
@@ -3,21 +3,26 @@ import { queuePostFlushCb } from "@vue/runtime-core";
export interface CommitScheduler {
schedule: () => void;
flush: () => Promise;
+ /** 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 | 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 {
- if (!scheduled && !hasPending) return Promise.resolve();
+ if (!scheduled && !hasPendingFlag) return Promise.resolve();
return new Promise((resolve) => {
resolveFlush = resolve;
});
}
- return { schedule, flush };
+ function hasPending(): boolean {
+ return hasPendingFlag;
+ }
+
+ return { schedule, flush, hasPending };
}
diff --git a/packages/testing/src/render.ts b/packages/testing/src/render.ts
index 0f0a577..cc0b66e 100644
--- a/packages/testing/src/render.ts
+++ b/packages/testing/src/render.ts
@@ -32,6 +32,7 @@ export interface RenderResult {
terminal: Terminal;
unmount(this: void): void;
waitUntilExit(this: void): Promise;
+ waitUntilRenderFlush(this: void): Promise;
}
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),
};
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index db7a869..6909a95 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21,6 +21,9 @@ catalogs:
is-in-ci:
specifier: ^1.0.0
version: 1.0.0
+ patch-console:
+ specifier: ^2.0.0
+ version: 2.0.0
strip-ansi:
specifier: ^7.2.0
version: 7.2.0
@@ -164,6 +167,9 @@ importers:
log-update:
specifier: ^6.0.0
version: 6.1.0
+ patch-console:
+ specifier: 'catalog:'
+ version: 2.0.0
slice-ansi:
specifier: ^7.1.0
version: 7.1.2
@@ -1550,6 +1556,10 @@ packages:
oxlint-tsgolint:
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:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2925,6 +2935,8 @@ snapshots:
'@oxlint/binding-win32-x64-msvc': 1.63.0
oxlint-tsgolint: 0.22.1
+ patch-console@2.0.0: {}
+
picocolors@1.1.1: {}
picomatch@4.0.4: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e3b956d..ed2dbcf 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -16,6 +16,7 @@ catalog:
"@vitejs/plugin-vue-jsx": ^5.1.5
chalk: ^5.6.2
is-in-ci: ^1.0.0
+ patch-console: ^2.0.0
strip-ansi: ^7.2.0
overrides:
vite: "catalog:"