diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index db4c0bf..2b22a8b 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -293,21 +293,6 @@ reactivity, lifecycle, component boundaries, current-props model, or API convent app handle are therefore Vue-shaped (`MountOptions` / `TuiApp`), not `render()`-shaped (`RenderOptions` / `Instance`). -#### Second `mount()` on a live stdout is an inert no-op - -- **Ink:** `render()` keeps one instance per stdout (`WeakMap`); a second - `render(node, {stdout})` on a stream that already has a live instance warns on stderr but - **reuses** that instance and `rerender`s the new tree into it. -- **vue-tui:** a second `mount()` on a still-live stdout warns on stderr and returns an - **inert handle**. It wires no second renderer and renders nothing; the first app's tree - stays on screen. `unmount()`/`teardown()` on that handle are complete no-ops (they never - touch the owner's stream or registry entry). -- **Why:** an app is an object you `mount()`, not a one-shot call that doubles as a - re-render. "Re-render the live instance" has no place to land when the second call is a - separate `TuiApp`; the correct path is `unmount()` then mount again (or keep one app and - update its reactive state). Returning an inert handle avoids adding a competing renderer - on the shared stream. Test: `instance-reuse-guard.test.tsx`. - #### Host-node type - `DOMElement` -> `TuiNode` - **Ink:** exports `DOMElement`, a DOM-emulation node (`nodeName` / `attributes` / @@ -361,6 +346,25 @@ These divergences are deliberate, but they are not strict supersets and are not driven by Vue's framework model or API conventions. vue-tui intentionally chooses a different runtime behavior, ownership rule, or out-of-contract handling. +### Second `mount()` on a live stdout is an inert no-op + +- **Ink:** `render()` keeps one instance per stdout (`WeakMap`); a second + `render(node, {stdout})` on a stream that already has a live instance warns on stderr but + **reuses** that instance and `rerender`s the new tree into it. +- **vue-tui:** a second `mount()` on a still-live stdout warns on stderr and returns an + **inert handle**. It wires no second renderer and renders nothing; the first app's tree + stays on screen. `unmount()`/`teardown()` on that handle never touch the owner's stream or + registry entry (`unmount()` only settles the inert handle's own exit promise). +- **Why:** a second `mount()` on a live stdout is a misuse (forgot to `unmount()`, a + re-render glitch fired `mount()` twice, or expecting `mount()` to re-render — it doesn't; + update reactive state for that). Ink treats it as unsupported and warns too. vue-tui fails + safe: it ignores the second mount, keeps the live app rendering, and warns with the two + recovery paths. It deliberately doesn't copy Ink's reuse-and-rerender: there's no clean + public path to it (`createApp` binds the tree to the app, so an Ink-style rerender would + mean reaching into the live app's container or tearing it down first), and on a misuse path + keeping the running app stable beats auto-tearing it down (which would churn on a re-render + glitch). Maintainer decision (2026-06-04): KEEP. Test: `instance-reuse-guard.test.tsx`. + ### Raw mode is owned for the interactive lifetime by default (`rawMode` option) - **Ink:** raw mode is **lazy / reference-counted to input hooks**. `useInput` / diff --git a/packages/runtime-tests/integration/lifecycle/instance-reuse-guard.test.tsx b/packages/runtime-tests/integration/lifecycle/instance-reuse-guard.test.tsx index 3aa0c75..ff8c23d 100644 --- a/packages/runtime-tests/integration/lifecycle/instance-reuse-guard.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/instance-reuse-guard.test.tsx @@ -49,9 +49,7 @@ test("warn + skip wiring when mount() is called on an already-live stdout", asyn // (a) A warning containing the key phrase was written to process.stderr. stderrSpy.mockRestore(); - expect(stderrWrites.join("")).toContain( - "createApp()/mount() was called again for the same stdout before the previous Vue TUI instance was unmounted", - ); + expect(stderrWrites.join("")).toContain("this stdout already has a live app"); // (b) The second mount did NOT wire a second renderer: no additional writes // to stdout happened immediately after the second mount (the second app @@ -96,9 +94,7 @@ test("warn + skip wiring when mount() is called on an already-live stdout", asyn const app3 = createApp(App); app3.mount({ stdout, stdin, stderr: process.stderr, interactive: false }); thirdSpy.mockRestore(); - expect(thirdWrites.join("")).toContain( - "createApp()/mount() was called again for the same stdout before the previous Vue TUI instance was unmounted", - ); + expect(thirdWrites.join("")).toContain("this stdout already has a live app"); // Cleanup: app1 still owns the stream; unmount it cleanly. app1.unmount(); @@ -128,7 +124,7 @@ test("unmounting first app allows a subsequent mount on the same stdout (no warn stderrSpy.mockRestore(); const warnText = stderrWrites.join(""); - expect(warnText).not.toContain("createApp()/mount() was called again for the same stdout"); + expect(warnText).not.toContain("this stdout already has a live app"); // app2 renders cleanly. await app2.waitUntilRenderFlush(); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 6c0d5bf..047cd98 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -502,7 +502,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // alternate-screen renderer cannot swallow it via patchConsole. if (liveInstances.has(stdout)) { process.stderr.write( - "Warning: createApp()/mount() was called again for the same stdout before the previous Vue TUI instance was unmounted. Reusing stdout across multiple mount() calls is unsupported. Call unmount() first.\n", + "Warning: this stdout already has a live app, so this mount() was ignored. To update the current view, change its reactive state instead of remounting; to mount another app, unmount() the existing one first.\n", ); // Mark this app as skipped so unmount()/teardown()/resolveExit() are // complete no-ops — they must never touch the owner's stream or WeakMap entry.