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:"