feat(runtime)!: replace useExit() with Ink-aligned useAppContext() (#69)

Ink's `useApp()` returns `{ exit, waitUntilRenderFlush }`. vue-tui previously
exposed only `exit()` via `useExit()` and kept `waitUntilRenderFlush` on the
`TuiApp` handle alone. Align the public surface with Ink: add `useAppContext()`
returning the same pair, and remove `useExit()`.

- thread `waitUntilRenderFlush` into the injected `AppContext` via a hoisted
  impl shared by the `TuiApp` handle and the composable, so both resolve
  identically
- add `useAppContext()`; delete `useExit()`; migrate all call sites, PTY
  fixtures, examples, READMEs and the public-API surface test
- port Ink's two "useApp waitUntilRenderFlush" tests; Ink's third relies on
  React concurrent mode (N/A in Vue)
- rewrite the ink-divergences entry: this is now a *naming* divergence
  (`useAppContext` vs `useApp`, mirroring `createApp` vs `render`), not a
  surface one — and fix the prior wrong claim that Ink's `useApp` returns
  stdin/stdout/stderr

The name is qualified (`useAppContext`, not `useApp`) so it doesn't read as the
Vue application instance (`createApp`/`app.mount`) — the same Vue-native naming
choice vue-tui already makes with `createApp()` vs Ink's `render()`.

BREAKING CHANGE: `useExit()` is removed. Replace `const exit = useExit()` with
`const { exit } = useAppContext()`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-30 19:57:52 +08:00
committed by GitHub
parent 6edc51b925
commit 9d1e4d7805
38 changed files with 226 additions and 122 deletions
@@ -1,7 +1,7 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, useExit } from "@vue-tui/runtime";
import { Box, Text, useAppContext } from "@vue-tui/runtime";
test("setup() throw rejects render()", async () => {
const Boom = defineComponent(() => {
@@ -32,20 +32,20 @@ test("render-time throw does not prevent unmount", async () => {
expect(() => unmount()).not.toThrow();
});
test("useExit() called with error rejects waitUntilExit", async () => {
test("useAppContext() called with error rejects waitUntilExit", async () => {
// Mirrors Ink's "exit on exit() with error" fixture test, adapted for
// render-based testing. Verifies exit(err) rejects the promise cleanly.
// Also covered by exit.test.tsx "exit(error) rejects waitUntilExit with the error".
let exitFn!: (err?: Error) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>running</Text>;
});
const { waitUntilExit } = await render(App);
const err = new Error("errored via useExit");
const err = new Error("errored via useAppContext");
exitFn(err);
await expect(waitUntilExit()).rejects.toBe(err);
@@ -2,15 +2,15 @@ import { Writable } from "node:stream";
import { defineComponent, onMounted, onScopeDispose } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { createApp, Text, useExit } from "@vue-tui/runtime";
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
import { makeFakeWritable, makeFakeStdin, isWriteBarrierChunk } from "./test-streams.ts";
test("useExit() triggers teardown and waitUntilExit resolves", async () => {
test("useAppContext() triggers teardown and waitUntilExit resolves", async () => {
let exitFn!: () => void;
let disposed = false;
const App = defineComponent(() => {
const exit = useExit();
const { exit } = useAppContext();
exitFn = exit;
onScopeDispose(() => {
disposed = true;
@@ -30,7 +30,7 @@ test("exit(error) rejects waitUntilExit with the error", async () => {
let exitFn!: (err: Error) => void;
const App = defineComponent(() => {
const exit = useExit();
const { exit } = useAppContext();
exitFn = exit;
return () => <Text>x</Text>;
});
@@ -56,7 +56,7 @@ test("unmount() after exit() is idempotent", async () => {
let exitFn!: () => void;
const App = defineComponent(() => {
const exit = useExit();
const { exit } = useAppContext();
exitFn = exit;
return () => <Text>x</Text>;
});
@@ -74,7 +74,7 @@ test("exit() called multiple times is idempotent", async () => {
let exitFn!: () => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>x</Text>;
});
@@ -89,7 +89,7 @@ test("waitUntilExit() resolves with result value passed to exit()", async () =>
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>hello from vue-tui</Text>;
});
@@ -105,7 +105,7 @@ test("waitUntilExit() resolves with object result value", async () => {
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>hello</Text>;
});
@@ -119,7 +119,7 @@ test("waitUntilExit() resolves with undefined when exit() called with no args",
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>hello</Text>;
});
@@ -135,7 +135,7 @@ test("onScopeDispose fires when exit(error) is called", async () => {
let disposed = false;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
onScopeDispose(() => {
disposed = true;
});
@@ -156,7 +156,7 @@ test("exit(error) followed by exit(value) still rejects", async () => {
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>hello</Text>;
});
@@ -175,7 +175,7 @@ test("exit(value) resolves with the FIRST value when called rapidly twice", asyn
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>hello</Text>;
});
@@ -194,7 +194,7 @@ test("exit(err1) then exit(err2) rejects with the FIRST error", async () => {
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>hello</Text>;
});
@@ -215,7 +215,7 @@ test("exit(value) then exit(error) resolves with the FIRST value", async () => {
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>hello</Text>;
});
@@ -229,8 +229,9 @@ test("exit(value) then exit(error) resolves with the FIRST value", async () => {
test("exit('late') after app.unmount() is a no-op (unmount value wins)", async () => {
// isUnmounting parity (Ink parity G33): app.unmount() runs teardown()+
// resolveExit() without setting exitInitiated. A retained useExit() called
// AFTER unmount has started teardown must be a complete no-op — it must not
// resolveExit() without setting exitInitiated. A retained exit() (from
// useAppContext()) called AFTER unmount has started teardown must be a
// complete no-op — it must not
// overwrite the resolved exit value. waitUntilExit resolves the original
// unmount value (undefined), NOT 'late'. Without the teardownStarted guard in
// exit(), the late exit captures 'late' into pendingExitResult and 'late'
@@ -238,7 +239,7 @@ test("exit('late') after app.unmount() is a no-op (unmount value wins)", async (
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>hello</Text>;
});
@@ -251,8 +252,8 @@ test("exit('late') after app.unmount() is a no-op (unmount value wins)", async (
});
test("retained exit() re-entered DURING unmount teardown writes is a no-op", async () => {
// isUnmounting parity (Ink parity G33), faithful reentrancy: a useExit()
// captured during setup is invoked re-entrantly from inside the stdout write
// isUnmounting parity (Ink parity G33), faithful reentrancy: an exit() (from
// useAppContext()) captured during setup is invoked re-entrantly from inside the stdout write
// that unmount()'s final commit performs. teardownStarted is already true at
// that point, so exit("reentrant") is a complete no-op and the original
// unmount value (undefined) wins. Without the teardownStarted guard the
@@ -278,7 +279,7 @@ test("retained exit() re-entered DURING unmount teardown writes is a no-op", asy
stdout.isTTY = true;
const App = defineComponent(() => {
const exit = useExit();
const { exit } = useAppContext();
onMounted(() => {
exitFn = exit;
});
@@ -306,7 +307,7 @@ test("single exit('x') resolves with 'x' (control)", async () => {
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
exitFn = useAppContext().exit;
return () => <Text>hello</Text>;
});
@@ -340,7 +341,7 @@ test("waitUntilExit resolves FIRST exit value when duplicate exits happen during
stdout.columns = 100;
const App = defineComponent(() => {
const exit = useExit();
const { exit } = useAppContext();
onMounted(() => {
exit("first");
setTimeout(() => exit("second"), 0);
@@ -388,7 +389,7 @@ test("waitUntilExit resolves FIRST exit value when exit is re-entered during unm
stdout.isTTY = true;
const App = defineComponent(() => {
const exit = useExit();
const { exit } = useAppContext();
onMounted(() => {
exitFn = exit;
shouldReenterExit = true;
@@ -437,7 +438,7 @@ test("exit with cross-realm Error resolves after stdout write callback", async (
const foreignError = vm.runInNewContext("new Error('boom')") as Error;
const App = defineComponent(() => {
const exit = useExit();
const { exit } = useAppContext();
onMounted(() => {
setTimeout(() => exit(foreignError), 0);
});
@@ -1,7 +1,7 @@
import { defineComponent, nextTick, onMounted, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { createApp, Text, useExit } from "@vue-tui/runtime";
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
import stripAnsi from "strip-ansi";
import {
makeFakeWritable,
@@ -229,7 +229,7 @@ test("waitUntilRenderFlush waits for unmount write callback", async () => {
test("waitUntilRenderFlush resolves after exit with error", async () => {
let exitFn!: (err: Error) => void;
const App = defineComponent(() => {
const exit = useExit();
const { exit } = useAppContext();
onMounted(() => {
exitFn = exit as (err: Error) => void;
});
@@ -248,12 +248,91 @@ test("waitUntilRenderFlush resolves after exit with error", async () => {
await app.waitUntilRenderFlush();
});
// useApp-level waitUntilRenderFlush tests:
// These test that waitUntilRenderFlush works when called from inside a component.
// In vue-tui, waitUntilRenderFlush is on the app instance, not a composable,
// so these are tested via the app.waitUntilRenderFlush() API above.
// The 2 "useApp waitUntilRenderFlush" tests from Ink are covered by the
// existing tests since vue-tui exposes the same API on the app object.
// useAppContext-level waitUntilRenderFlush tests (Ink parity, ported from Ink
// render.tsx "useApp waitUntilRenderFlush …"): waitUntilRenderFlush is reachable
// from INSIDE a component via useAppContext() — Ink's useApp() returns the same
// { exit, waitUntilRenderFlush } pair. Ink's third "queued in same effect tick"
// test relies on React `concurrent: true` (concurrent mode is N/A in Vue — see
// .agents/docs/ink-divergences.md), so only the first two are ported.
test("useAppContext waitUntilRenderFlush resolves after the first frame write callback", async () => {
let didInitialWriteCallbackFire = false;
let didFlushResolve = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) => !isWriteBarrierChunk(chunk),
onDelayElapsed: () => {
didInitialWriteCallbackFire = true;
},
});
const App = defineComponent(() => {
const { exit, waitUntilRenderFlush } = useAppContext();
onMounted(() => {
void (async () => {
await waitUntilRenderFlush();
didFlushResolve = true;
exit();
})();
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilExit();
expect(didInitialWriteCallbackFire).toBe(true);
expect(didFlushResolve).toBe(true);
});
test("useAppContext waitUntilRenderFlush waits for state update frame flush", async () => {
let didWorldWriteCallbackFire = false;
let didFlushResolve = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) =>
!isWriteBarrierChunk(chunk) &&
stripAnsi(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)).includes(
"World",
),
onDelayElapsed: () => {
didWorldWriteCallbackFire = true;
},
});
const text = shallowRef("Hello");
const App = defineComponent(() => {
const { exit, waitUntilRenderFlush } = useAppContext();
onMounted(() => {
void (async () => {
// Settle the initial "Hello" frame first (not delayed by the harness,
// which only delays the "World" chunk) so the subsequent "World" write
// is unambiguously a second, state-update frame — mirroring the stable
// app-level "waits for rerender write callback" test above.
await waitUntilRenderFlush();
text.value = "World";
await nextTick();
await nextTick();
await waitUntilRenderFlush();
didFlushResolve = true;
exit();
})();
});
return () => <Text>{text.value}</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilExit();
expect(didWorldWriteCallbackFire).toBe(true);
expect(didFlushResolve).toBe(true);
});
// --- clear() API test ---