feat: exit() accepts result value, waitUntilExit resolves with it

Change exit(error?: Error) to exit(errorOrResult?: unknown). When an
Error is passed, waitUntilExit rejects; otherwise it resolves with the
value. This matches Ink's behavior where exit("hello") resolves the
promise with "hello".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 00:10:52 +08:00
parent 97ee43ea14
commit bcfdaddec7
5 changed files with 47 additions and 16 deletions
@@ -82,10 +82,9 @@ test("exit() called multiple times is idempotent", async () => {
await waitUntilExit();
});
test("waitUntilExit() resolves after exit() with result value", async () => {
// Mirrors Ink's "exit on exit() with result value" — vue-tui's exit()
// resolves cleanly (no value); just verifies the promise resolves.
let exitFn!: () => void;
test("waitUntilExit() resolves with result value passed to exit()", async () => {
// Mirrors Ink's "exit on exit() with result value"
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
@@ -95,6 +94,34 @@ test("waitUntilExit() resolves after exit() with result value", async () => {
const { lastFrame, waitUntilExit } = await render(App);
expect(lastFrame()).toContain("hello from vue-tui");
exitFn("hello from ink");
await expect(waitUntilExit()).resolves.toBe("hello from ink");
});
test("waitUntilExit() resolves with object result value", async () => {
// Mirrors Ink's "exit on exit() with object result"
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
return () => <Text>hello</Text>;
});
const { waitUntilExit } = await render(App);
const resultObj = { message: "hello from ink object" };
exitFn(resultObj);
await expect(waitUntilExit()).resolves.toBe(resultObj);
});
test("waitUntilExit() resolves with undefined when exit() called with no args", async () => {
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useExit();
return () => <Text>hello</Text>;
});
const { waitUntilExit } = await render(App);
exitFn();
await expect(waitUntilExit()).resolves.toBeUndefined();
});
+4 -3
View File
@@ -3,10 +3,11 @@ import { AppContextKey } from "../context.ts";
/**
* Returns the exit function so a component can end the app from inside the
* tree. Pass an Error to reject `app.waitUntilExit()` (and any awaiter); call
* with no args to resolve cleanly.
* tree. Pass an Error to reject `app.waitUntilExit()` (and any awaiter); pass
* any other value to resolve with it as the result; call with no args to
* resolve with undefined.
*/
export function useExit(): (error?: Error) => void {
export function useExit(): (errorOrResult?: unknown) => void {
const ctx = inject(AppContextKey);
if (!ctx) throw new Error("useExit() must be called inside a vue-tui render tree");
return ctx.exit;
+1 -1
View File
@@ -2,7 +2,7 @@ import type { InjectionKey, ShallowRef } from "vue";
import type { EventEmitter } from "node:events";
export interface AppContext {
exit: (error?: Error) => void;
exit: (errorOrResult?: unknown) => void;
stdout: NodeJS.WriteStream;
stderr: NodeJS.WriteStream;
stdin: NodeJS.ReadStream;
+10 -7
View File
@@ -31,7 +31,7 @@ export interface MountOptions {
export interface TuiApp extends Omit<VueApp<TuiNode>, "mount"> {
mount(options?: MountOptions): ComponentPublicInstance;
waitUntilExit(): Promise<void>;
waitUntilExit(): Promise<unknown>;
}
type RootProps = Record<string, unknown>;
@@ -39,9 +39,9 @@ type RootProps = Record<string, unknown>;
export function createApp(root: Component, rootProps?: RootProps | null): TuiApp {
// exit promise — created at createApp time so waitUntilExit() works even
// before mount (it just hangs until mount + exit).
let exitResolve!: () => void;
let exitResolve!: (result?: unknown) => void;
let exitReject!: (e: Error) => void;
const exitPromise = new Promise<void>((res, rej) => {
const exitPromise = new Promise<unknown>((res, rej) => {
exitResolve = res;
exitReject = rej;
});
@@ -120,14 +120,17 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
mountedDebug = debug;
const appContext: AppContext = {
exit(err?: Error) {
if (err) exitReject(err);
exit(errorOrResult?: unknown) {
// Defer teardown to a microtask: exit() is frequently called from
// inside the Vue update cycle (useInput handler, setup(), errorHandler)
// and unmounting synchronously would tear Vue down mid-flush.
queueMicrotask(() => {
teardown();
exitResolve();
if (errorOrResult instanceof Error) {
exitReject(errorOrResult);
} else {
exitResolve(errorOrResult);
}
});
},
stdout,
@@ -238,7 +241,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
exitResolve();
};
app.waitUntilExit = function waitUntilExit(): Promise<void> {
app.waitUntilExit = function waitUntilExit(): Promise<unknown> {
return exitPromise;
};
+1 -1
View File
@@ -31,7 +31,7 @@ export interface RenderResult {
};
terminal: Terminal;
unmount(this: void): void;
waitUntilExit(this: void): Promise<void>;
waitUntilExit(this: void): Promise<unknown>;
}
function trimFrame(raw: string): string {