docs(divergences): reframe second-mount no-op as an intentional, blessed divergence (#136)

A second mount() on a live stdout stays a warn + no-op (no behavior change).
This reframes it as a deliberate, maintainer-blessed choice instead of the
vaguely-justified entry it was.

- warning (render.ts): rewrite the stderr message to state the situation plus
  the two recovery paths (update reactive state, or unmount() the existing app
  first), replacing the old "unsupported / call unmount() first" phrasing.
- divergence doc: rewrite the Why with the real rationale (it is a misuse path;
  Ink warns it is unsupported too; vue-tui fails safe by keeping the live app
  and warning; there is no clean public path to Ink's reuse-and-rerender under
  the createApp model). Move the entry from "Vue-Idiomatic Choices" to
  "Intentional Divergence Choices"; record "Maintainer decision: KEEP"; tighten
  the inert-handle wording (unmount() only settles its own exit promise).
- tests: retarget the instance-reuse-guard assertions to the new warning text,
  including a negative assertion that previously matched a stale substring.

Verified: vp run ready (integration 1104 + PTY 123 passing, lint/type/build).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-04 19:17:53 +08:00
committed by GitHub
parent 929f3952bb
commit 5d071d44ab
3 changed files with 23 additions and 23 deletions
+19 -15
View File
@@ -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 app handle are therefore Vue-shaped (`MountOptions` / `TuiApp`), not `render()`-shaped
(`RenderOptions` / `Instance`). (`RenderOptions` / `Instance`).
#### Second `mount()` on a live stdout is an inert no-op
- **Ink:** `render()` keeps one instance per stdout (`WeakMap<WriteStream, Ink>`); 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` #### Host-node type - `DOMElement` -> `TuiNode`
- **Ink:** exports `DOMElement`, a DOM-emulation node (`nodeName` / `attributes` / - **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 driven by Vue's framework model or API conventions. vue-tui intentionally chooses a
different runtime behavior, ownership rule, or out-of-contract handling. 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<WriteStream, Ink>`); 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) ### Raw mode is owned for the interactive lifetime by default (`rawMode` option)
- **Ink:** raw mode is **lazy / reference-counted to input hooks**. `useInput` / - **Ink:** raw mode is **lazy / reference-counted to input hooks**. `useInput` /
@@ -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. // (a) A warning containing the key phrase was written to process.stderr.
stderrSpy.mockRestore(); stderrSpy.mockRestore();
expect(stderrWrites.join("")).toContain( expect(stderrWrites.join("")).toContain("this stdout already has a live app");
"createApp()/mount() was called again for the same stdout before the previous Vue TUI instance was unmounted",
);
// (b) The second mount did NOT wire a second renderer: no additional writes // (b) The second mount did NOT wire a second renderer: no additional writes
// to stdout happened immediately after the second mount (the second app // 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); const app3 = createApp(App);
app3.mount({ stdout, stdin, stderr: process.stderr, interactive: false }); app3.mount({ stdout, stdin, stderr: process.stderr, interactive: false });
thirdSpy.mockRestore(); thirdSpy.mockRestore();
expect(thirdWrites.join("")).toContain( expect(thirdWrites.join("")).toContain("this stdout already has a live app");
"createApp()/mount() was called again for the same stdout before the previous Vue TUI instance was unmounted",
);
// Cleanup: app1 still owns the stream; unmount it cleanly. // Cleanup: app1 still owns the stream; unmount it cleanly.
app1.unmount(); app1.unmount();
@@ -128,7 +124,7 @@ test("unmounting first app allows a subsequent mount on the same stdout (no warn
stderrSpy.mockRestore(); stderrSpy.mockRestore();
const warnText = stderrWrites.join(""); 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. // app2 renders cleanly.
await app2.waitUntilRenderFlush(); await app2.waitUntilRenderFlush();
+1 -1
View File
@@ -502,7 +502,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// alternate-screen renderer cannot swallow it via patchConsole. // alternate-screen renderer cannot swallow it via patchConsole.
if (liveInstances.has(stdout)) { if (liveInstances.has(stdout)) {
process.stderr.write( 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 // Mark this app as skipped so unmount()/teardown()/resolveExit() are
// complete no-ops — they must never touch the owner's stream or WeakMap entry. // complete no-ops — they must never touch the owner's stream or WeakMap entry.