From 814c482d6d3da51bf9d6d35ce71e992fa4b79489 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Fri, 12 Jun 2026 02:23:32 +0800 Subject: [PATCH] fix(runtime): install console patch before first mount so initial [Vue warn] is filtered (#151) A [Vue warn] emitted during the initial mount (e.g. the missing-render- function warn from a root setup() throw) escaped the stderr filter because mount() installed the console patch only after originalMount. Ink patches in its constructor before the first React render (ink.tsx:435-436); move the install before originalMount to match. The mount-throw catch already restores the console via teardown(). Verified red-first against real Ink v7.0.4 (audit e10): Ink's stderr stays empty for a render-throwing component; vue-tui's initial-mount warn reached a real PTY before this fix. Co-authored-by: Claude Fable 5 --- .agents/docs/ink-divergences.md | 22 +++-- .../lifecycle/patch-console.test.tsx | 88 ++++++++++++++++++- .../fixtures/patch-console-initial-mount.tsx | 20 +++++ .../pty/patch-console-initial-mount.test.ts | 26 ++++++ packages/runtime/src/render.ts | 50 ++++++----- 5 files changed, 173 insertions(+), 33 deletions(-) create mode 100644 packages/runtime-tests/integration/pty/fixtures/patch-console-initial-mount.tsx create mode 100644 packages/runtime-tests/integration/pty/patch-console-initial-mount.test.ts diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index c605a42..b400cd1 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -206,18 +206,22 @@ current-props model, or API conventions. #### A `setup()`-throwing component emits a dev-only `[Vue warn]` on stderr - **Ink:** a component that throws during render surfaces only through the error overview / - exit path; React emits no extra framework warning. + exit path; React emits no extra framework warning on stderr (verified: stderr stays + empty). - **vue-tui:** in a **development** build, a component whose `setup()` throws additionally produces Vue's own `[Vue warn]` lines on stderr (for example, the missing-render-function - warning) that Ink has no analog for. In interactive mode `patchConsole` filters - `[Vue warn]` out of the frame; outside that path (debug, non-patched stderr) it surfaces. + warning) that Ink has no analog for. While console patching is active (the default; + disabled by `patchConsole: false` or `debug`, independent of interactive mode), vue-tui + treats the `[Vue warn]` prefix as Vue's framework-diagnostics channel and drops those + stderr lines. The patch is installed before the first mount (matching Ink, which patches + before the first render), so a `setup()` throw during the **initial** mount is filtered + too. With patching off, every `[Vue warn]` surfaces. - **Why:** these warnings come from Vue itself and are **dev-only** (stripped in production - builds); they have no effect on stdout output or the exit code. Documented so the stray - warn is not mistaken for vue-tui behavior: it is Vue's framework diagnostics. While - console patching is active, vue-tui treats the `[Vue warn]` prefix as that framework - diagnostics channel and filters it. This may also filter user-authored stderr logs that - intentionally use the same reserved prefix; use a different application prefix when that - output must be preserved. Maintainer decision (2026-06-06): KEEP. + builds); they never enter the stdout frame and do not change the exit path. Documented so + the stray warn is not mistaken for vue-tui behavior: it is Vue's framework diagnostics. + The prefix filter may also drop user-authored stderr logs that intentionally reuse the + reserved `[Vue warn]` prefix; use a different application prefix when that output must be + preserved. Maintainer decision (2026-06-06): KEEP. #### React concurrent mode diff --git a/packages/runtime-tests/integration/lifecycle/patch-console.test.tsx b/packages/runtime-tests/integration/lifecycle/patch-console.test.tsx index a2f9e96..6fb43d2 100644 --- a/packages/runtime-tests/integration/lifecycle/patch-console.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/patch-console.test.tsx @@ -1,7 +1,18 @@ -import { defineComponent } from "vue"; +import { Console as NodeConsole } from "node:console"; +import { defineComponent, h } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Text } from "@vue-tui/runtime"; +import { createApp, Text } from "@vue-tui/runtime"; +import { captureWrites, makeFakeStdin, makeFakeWritable } from "./test-streams.ts"; + +// vitest's worker console is a custom Console instance that LACKS the +// `Console` constructor property a real Node global console always has. +// patch-console needs `new console.Console(...)`, and render.ts degrades +// gracefully (no patch at all) when it's missing — which would make the +// filter/restore tests below pass vacuously. Restore the real-Node console +// shape so the patch actually installs (safe to leave for the file's +// lifetime: vitest isolates workers per test file). +(console as { Console?: typeof NodeConsole }).Console ??= NodeConsole; test("patchConsole is disabled in debug mode (testing render uses debug)", async () => { // The testing render() helper uses debug: true, which auto-disables @@ -19,3 +30,76 @@ test("patchConsole option defaults to true and can be set to false", async () => const { lastFrame } = await render(App); expect(lastFrame()).toContain("hello"); }); + +// The console patch must be installed BEFORE the first Vue mount (Ink patches +// in its constructor, ink.tsx:435-436, before the first React render). A root +// whose setup() throws makes Vue emit its dev-only "[Vue warn]: Component is +// missing template or render function." DURING the initial mount — with the +// patch installed only after mount, that warn escaped to the real console even +// with patchConsole on. (Defined inline, not in a fixture module: see the +// SSR register-helper note in error-overview.test.tsx.) +test("a [Vue warn] from the initial mount is filtered (patch installed before mount)", async () => { + const SetupThrower = defineComponent(() => { + throw new Error("setup boom"); + }); + + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const stderrWrites = captureWrites(stderr); + const { stream: stdin } = makeFakeStdin(); + + // Recorder stands in for the REAL console.warn: anything that reaches it + // escaped the patch (the late-patch bug routed initial-mount warns here). + const realWarn = console.warn; + const escapedWarns: string[] = []; + console.warn = (...args: unknown[]) => { + escapedWarns.push(args.map(String).join(" ")); + }; + + try { + const app = createApp(SetupThrower); + app.mount({ stdout, stderr, stdin, exitOnCtrlC: false, maxFps: 0 }); + await expect(app.waitUntilExit()).rejects.toThrow("setup boom"); + } finally { + console.warn = realWarn; + } + + expect(escapedWarns.filter((w) => w.startsWith("[Vue warn]"))).toEqual([]); + // The filter DROPS the warn rather than routing it to the app's stderr. + expect(stderrWrites.filter((w) => w.startsWith("[Vue warn]"))).toEqual([]); +}); + +test("console is restored when mount throws synchronously", () => { + // A vnode whose `type` getter throws during the renderer's patch phase + // bypasses onErrorCaptured, so originalMount throws SYNCHRONOUSLY (same + // repro as cursor-commit-path's DEFECT 2). With the patch now installed + // before mount, the throw path must still restore the console via the + // mount-catch teardown() — otherwise the [Vue warn] filter would keep + // swallowing console output for the rest of the process. + const ThrowOnPatchApp = defineComponent(() => { + return () => { + const vnode = h("div"); + Object.defineProperty(vnode, "type", { + get() { + throw new Error("boom from vnode type getter"); + }, + }); + return vnode as never; + }; + }); + + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + + const logBefore = console.log; + const warnBefore = console.warn; + + const app = createApp(ThrowOnPatchApp); + expect(() => app.mount({ stdout, stderr, stdin, exitOnCtrlC: false, maxFps: 0 })).toThrow( + "boom from vnode type getter", + ); + + expect(console.log).toBe(logBefore); + expect(console.warn).toBe(warnBefore); +}); diff --git a/packages/runtime-tests/integration/pty/fixtures/patch-console-initial-mount.tsx b/packages/runtime-tests/integration/pty/fixtures/patch-console-initial-mount.tsx new file mode 100644 index 0000000..4305fa8 --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/patch-console-initial-mount.tsx @@ -0,0 +1,20 @@ +import { createApp } from "@vue-tui/runtime"; +import { defineComponent } from "vue"; + +// Root setup() throws during the INITIAL mount. In a dev build Vue then emits +// its "[Vue warn]: Component is missing template or render function." line on +// stderr. patchConsole is at its default (on) and debug is off, so that warn +// must be filtered even though it fires during the first mount. +const App = defineComponent(() => { + throw new Error("setup boom"); +}); + +const app = createApp(App); +app.mount(); + +try { + await app.waitUntilExit(); + console.log("waitUntilExit:resolved"); +} catch (error: unknown) { + console.log(`waitUntilExit:rejected:${(error as Error).message}`); +} diff --git a/packages/runtime-tests/integration/pty/patch-console-initial-mount.test.ts b/packages/runtime-tests/integration/pty/patch-console-initial-mount.test.ts new file mode 100644 index 0000000..3061dba --- /dev/null +++ b/packages/runtime-tests/integration/pty/patch-console-initial-mount.test.ts @@ -0,0 +1,26 @@ +import { test as it, expect } from "vite-plus/test"; +import stripAnsi from "strip-ansi"; +import { run } from "./helpers/run.ts"; + +// The console patch must be installed BEFORE the first Vue mount (Ink patches +// in its constructor, ink.tsx:435-436, before the first React render). A root +// whose setup() throws makes Vue emit its dev-only "[Vue warn]: Component is +// missing template or render function." DURING the initial mount — with the +// patch installed only after mount, that warn escaped to the real terminal +// even with patchConsole on. +it("filters a [Vue warn] emitted during the initial mount (patchConsole default)", async () => { + const output = await run("patch-console-initial-mount"); + + // waitUntilExit() still rejects with the setup error. + expect(output).toContain("waitUntilExit:rejected:setup boom"); + + const plain = stripAnsi(output); + + // The error overview still renders (" ERROR " after ANSI strip). + expect(plain).toContain(" ERROR "); + expect(plain).toContain("setup boom"); + + // No [Vue warn] line reaches the terminal. + const vueWarnLines = plain.split(/\r?\n/).filter((line) => line.startsWith("[Vue warn]")); + expect(vueWarnLines).toEqual([]); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 5953113..7865c37 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -1120,6 +1120,34 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } mountedAlternateScreen = alternateScreen; + // Patch console.log/warn/error etc. to route through writeToStdout / + // writeToStderr so console output doesn't corrupt the rendered frame. + // Installed BEFORE originalMount (matching Ink, which patches in its + // constructor before the first render — ink.tsx:435-436): a dev-only + // [Vue warn] emitted DURING the initial mount (e.g. the missing-render- + // function warn when the root's setup() throws) must hit the filter too. + // The mount-throw catch below runs teardown(), which restores the console, + // so a synchronous mount failure cannot leak a patched console. + // 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. + } + } + // No eager mount-time cursor hide here (matching Ink). Ink hides the cursor // LAZILY: the non-alt-screen hide comes from log-update's isTTY-gated // cliCursor.hide on the first render that actually writes (log-update.ts: @@ -1258,28 +1286,6 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp mountedUnsubscribeExit = onExit(() => teardown(true), { alwaysLast: false }); } - // 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; };