From bcfdaddec71e1345e2cb147d61e8f9c5c4c1acc2 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 26 May 2026 00:10:52 +0800 Subject: [PATCH] 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) --- .../integration/lifecycle/exit.test.tsx | 35 ++++++++++++++++--- packages/runtime/src/composables/useExit.ts | 7 ++-- packages/runtime/src/context.ts | 2 +- packages/runtime/src/render.ts | 17 +++++---- packages/testing/src/render.ts | 2 +- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/packages/runtime-tests/integration/lifecycle/exit.test.tsx b/packages/runtime-tests/integration/lifecycle/exit.test.tsx index 96c3824..2ec9e3a 100644 --- a/packages/runtime-tests/integration/lifecycle/exit.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/exit.test.tsx @@ -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 () => hello; + }); + + 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 () => hello; + }); + + const { waitUntilExit } = await render(App); exitFn(); await expect(waitUntilExit()).resolves.toBeUndefined(); }); diff --git a/packages/runtime/src/composables/useExit.ts b/packages/runtime/src/composables/useExit.ts index e2cac8d..a100c1c 100644 --- a/packages/runtime/src/composables/useExit.ts +++ b/packages/runtime/src/composables/useExit.ts @@ -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; diff --git a/packages/runtime/src/context.ts b/packages/runtime/src/context.ts index d413a29..da3a513 100644 --- a/packages/runtime/src/context.ts +++ b/packages/runtime/src/context.ts @@ -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; diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index a99b1e5..8af6e5c 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -31,7 +31,7 @@ export interface MountOptions { export interface TuiApp extends Omit, "mount"> { mount(options?: MountOptions): ComponentPublicInstance; - waitUntilExit(): Promise; + waitUntilExit(): Promise; } type RootProps = Record; @@ -39,9 +39,9 @@ type RootProps = Record; 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((res, rej) => { + const exitPromise = new Promise((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 { + app.waitUntilExit = function waitUntilExit(): Promise { return exitPromise; }; diff --git a/packages/testing/src/render.ts b/packages/testing/src/render.ts index 9ff89d9..0c4f5f0 100644 --- a/packages/testing/src/render.ts +++ b/packages/testing/src/render.ts @@ -31,7 +31,7 @@ export interface RenderResult { }; terminal: Terminal; unmount(this: void): void; - waitUntilExit(this: void): Promise; + waitUntilExit(this: void): Promise; } function trimFrame(raw: string): string {