diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 6598fd6..def4bde 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -113,6 +113,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp let mountedAppContext: AppContext | null = null; let mountedResizeHandler: (() => void) | null = null; let mountedExitListener: (() => void) | null = null; + let mountedBeforeExitHandler: (() => void) | null = null; let mountedDebug = false; let mountedInteractive = true; let mountedRawMode = false; @@ -125,6 +126,31 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // even though it's not used until mount. let scheduledCommit: () => void = () => {}; + // Pending exit state — stored so resolveExit() can flush stdout before + // settling the exit promise. + let pendingExitError: unknown = undefined; + let pendingExitResult: unknown = undefined; + + function resolveExit() { + const stdout = mountedAppContext?.stdout ?? process.stdout; + const canWrite = stdout && !stdout.destroyed && !(stdout as any).writableEnded; + const hasWritableState = (stdout as any)._writableState !== undefined; + + const finish = () => { + if (pendingExitError instanceof Error) { + exitReject(pendingExitError); + } else { + exitResolve(pendingExitResult); + } + }; + + if (canWrite && hasWritableState) { + stdout.write("", () => finish()); + } else { + setImmediate(() => finish()); + } + } + let teardownStarted = false; function teardown() { if (teardownStarted) return; @@ -159,6 +185,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp if (mountedExitListener) { process.off("exit", mountedExitListener); } + if (mountedBeforeExitHandler) { + process.off("beforeExit", mountedBeforeExitHandler); + mountedBeforeExitHandler = null; + } if (mountedRawMode && mountedAppContext) { mountedAppContext.setRawMode(false); mountedRawMode = false; @@ -296,10 +326,11 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp queueMicrotask(() => { teardown(); if (errorOrResult instanceof Error) { - exitReject(errorOrResult); + pendingExitError = errorOrResult; } else { - exitResolve(errorOrResult); + pendingExitResult = errorOrResult; } + resolveExit(); }); }, stdout, @@ -486,10 +517,16 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp app.unmount = function unmount(): void { teardown(); - exitResolve(); + resolveExit(); }; app.waitUntilExit = function waitUntilExit(): Promise { + if (!mountedBeforeExitHandler) { + mountedBeforeExitHandler = () => { + app.unmount(); + }; + process.once("beforeExit", mountedBeforeExitHandler); + } return exitPromise; }; diff --git a/packages/testing/src/render.ts b/packages/testing/src/render.ts index cc0b66e..dbbae76 100644 --- a/packages/testing/src/render.ts +++ b/packages/testing/src/render.ts @@ -77,11 +77,17 @@ export async function render( earlyError = e as Error; }); - // Flush the Vue queue. Chain: onErrorCaptured → nextTick → exit → queueMicrotask → reject + // Flush the Vue queue. Chain: onErrorCaptured → nextTick → exit → queueMicrotask + // → teardown → resolveExit() → stdout.write("", callback) → reject. + // The stdout write barrier fires via process.nextTick (inside stream internals), + // so we need setImmediate (runs after all process.nextTick callbacks), then one + // more microtask yield so the .catch() handler on exitPromise can set earlyError. await nextTick(); await nextTick(); await Promise.resolve(); await Promise.resolve(); + await new Promise((r) => setImmediate(r)); + await Promise.resolve(); if (earlyError) { throw earlyError;