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(); await waitUntilExit();
}); });
test("waitUntilExit() resolves after exit() with result value", async () => { test("waitUntilExit() resolves with result value passed to exit()", async () => {
// Mirrors Ink's "exit on exit() with result value" — vue-tui's exit() // Mirrors Ink's "exit on exit() with result value"
// resolves cleanly (no value); just verifies the promise resolves. let exitFn!: (errorOrResult?: unknown) => void;
let exitFn!: () => void;
const App = defineComponent(() => { const App = defineComponent(() => {
exitFn = useExit(); exitFn = useExit();
@@ -95,6 +94,34 @@ test("waitUntilExit() resolves after exit() with result value", async () => {
const { lastFrame, waitUntilExit } = await render(App); const { lastFrame, waitUntilExit } = await render(App);
expect(lastFrame()).toContain("hello from vue-tui"); 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(); exitFn();
await expect(waitUntilExit()).resolves.toBeUndefined(); 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 * 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 * tree. Pass an Error to reject `app.waitUntilExit()` (and any awaiter); pass
* with no args to resolve cleanly. * 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); const ctx = inject(AppContextKey);
if (!ctx) throw new Error("useExit() must be called inside a vue-tui render tree"); if (!ctx) throw new Error("useExit() must be called inside a vue-tui render tree");
return ctx.exit; return ctx.exit;
+1 -1
View File
@@ -2,7 +2,7 @@ import type { InjectionKey, ShallowRef } from "vue";
import type { EventEmitter } from "node:events"; import type { EventEmitter } from "node:events";
export interface AppContext { export interface AppContext {
exit: (error?: Error) => void; exit: (errorOrResult?: unknown) => void;
stdout: NodeJS.WriteStream; stdout: NodeJS.WriteStream;
stderr: NodeJS.WriteStream; stderr: NodeJS.WriteStream;
stdin: NodeJS.ReadStream; stdin: NodeJS.ReadStream;
+10 -7
View File
@@ -31,7 +31,7 @@ export interface MountOptions {
export interface TuiApp extends Omit<VueApp<TuiNode>, "mount"> { export interface TuiApp extends Omit<VueApp<TuiNode>, "mount"> {
mount(options?: MountOptions): ComponentPublicInstance; mount(options?: MountOptions): ComponentPublicInstance;
waitUntilExit(): Promise<void>; waitUntilExit(): Promise<unknown>;
} }
type RootProps = Record<string, unknown>; type RootProps = Record<string, unknown>;
@@ -39,9 +39,9 @@ type RootProps = Record<string, unknown>;
export function createApp(root: Component, rootProps?: RootProps | null): TuiApp { export function createApp(root: Component, rootProps?: RootProps | null): TuiApp {
// exit promise — created at createApp time so waitUntilExit() works even // exit promise — created at createApp time so waitUntilExit() works even
// before mount (it just hangs until mount + exit). // before mount (it just hangs until mount + exit).
let exitResolve!: () => void; let exitResolve!: (result?: unknown) => void;
let exitReject!: (e: Error) => void; let exitReject!: (e: Error) => void;
const exitPromise = new Promise<void>((res, rej) => { const exitPromise = new Promise<unknown>((res, rej) => {
exitResolve = res; exitResolve = res;
exitReject = rej; exitReject = rej;
}); });
@@ -120,14 +120,17 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
mountedDebug = debug; mountedDebug = debug;
const appContext: AppContext = { const appContext: AppContext = {
exit(err?: Error) { exit(errorOrResult?: unknown) {
if (err) exitReject(err);
// Defer teardown to a microtask: exit() is frequently called from // Defer teardown to a microtask: exit() is frequently called from
// inside the Vue update cycle (useInput handler, setup(), errorHandler) // inside the Vue update cycle (useInput handler, setup(), errorHandler)
// and unmounting synchronously would tear Vue down mid-flush. // and unmounting synchronously would tear Vue down mid-flush.
queueMicrotask(() => { queueMicrotask(() => {
teardown(); teardown();
exitResolve(); if (errorOrResult instanceof Error) {
exitReject(errorOrResult);
} else {
exitResolve(errorOrResult);
}
}); });
}, },
stdout, stdout,
@@ -238,7 +241,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
exitResolve(); exitResolve();
}; };
app.waitUntilExit = function waitUntilExit(): Promise<void> { app.waitUntilExit = function waitUntilExit(): Promise<unknown> {
return exitPromise; return exitPromise;
}; };
+1 -1
View File
@@ -31,7 +31,7 @@ export interface RenderResult {
}; };
terminal: Terminal; terminal: Terminal;
unmount(this: void): void; unmount(this: void): void;
waitUntilExit(this: void): Promise<void>; waitUntilExit(this: void): Promise<unknown>;
} }
function trimFrame(raw: string): string { function trimFrame(raw: string): string {