diff --git a/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx b/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx
index 935b257..73023c4 100644
--- a/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx
+++ b/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx
@@ -23,12 +23,12 @@ test("render-time throw does not prevent unmount", async () => {
expect(lastFrame()).toContain("ok");
trigger.value = true;
- try {
- await nextTick();
- } catch {
- // swallow the render error
- }
+ // Error boundary catches the render error and routes through exit()
+ await nextTick();
+ await nextTick();
+ await Promise.resolve();
+ // After exit(), teardown has run. unmount() should be idempotent/no-throw.
expect(() => unmount()).not.toThrow();
});
@@ -51,30 +51,57 @@ test("useExit() called with error rejects waitUntilExit", async () => {
await expect(waitUntilExit()).rejects.toBe(err);
});
-// --- Tests that cannot be ported due to vue-tui runtime limitations ---
+// --- Error boundary tests (previously blocked by yoga WASM crashes) ---
-test.todo(
- "nested component setup error rejects waitUntilExit — " +
- "errorHandler is installed AFTER mount, so errors during initial mount " +
- "propagate synchronously instead of routing through exit(err). " +
- "Additionally, throwing during mount corrupts the WASM yoga tree, " +
- "making teardown unreliable. Requires runtime-level pre-mount error handling.",
-);
+test("nested component setup error rejects waitUntilExit", async () => {
+ const err = new Error("setup boom nested");
+ const Child = defineComponent(() => {
+ throw err;
+ });
+ const App = defineComponent(() => () => );
+ await expect(render(App)).rejects.toThrow("setup boom nested");
+});
-test.todo(
- "does not emit unhandledRejection when render exits with an error and waitUntilExit is unused — " +
- "in vue-tui, setup errors thrown during mount surface via render() rejection " +
- "and may also produce unhandledRejection events from Vue's internal promise chains " +
- "before our exitPromise.catch() guard takes effect. Requires engine-level fix.",
-);
+test("does not emit unhandledRejection when render exits with an error and waitUntilExit is unused", async () => {
+ const unhandledErrors: Error[] = [];
+ const handler = (reason: unknown) => {
+ unhandledErrors.push(reason as Error);
+ };
+ process.on("unhandledRejection", handler);
-test.todo(
- "error in component triggered after mount routes through errorHandler — " +
- "render-function throws during a reactive re-render (post-mount) cause " +
- "yoga WASM table index out-of-bounds crashes that corrupt the layout engine. " +
- "The errorHandler is called but the process state is unrecoverable. " +
- "Requires WASM error isolation or render-phase error recovery in the runtime.",
-);
+ try {
+ const Boom = defineComponent(() => {
+ throw new Error("no-listener boom");
+ });
+ await render(Boom).catch(() => {});
+ // Give a tick for any stray rejections to surface
+ await new Promise((r) => setTimeout(r, 10));
+ expect(unhandledErrors).toHaveLength(0);
+ } finally {
+ process.off("unhandledRejection", handler);
+ }
+});
+
+test("error in component triggered after mount routes through exit", async () => {
+ const trigger = shallowRef(false);
+ const App = defineComponent(() => {
+ return () => {
+ if (trigger.value) throw new Error("post-mount boom");
+ return ok;
+ };
+ });
+
+ const { waitUntilExit, lastFrame } = await render(App);
+ expect(lastFrame()).toContain("ok");
+
+ trigger.value = true;
+ // Flush the render + error boundary nextTick + exit microtask
+ await nextTick();
+ await nextTick();
+ await Promise.resolve();
+
+ await expect(waitUntilExit()).rejects.toThrow("post-mount boom");
+});
// --- Ink error validation tests ---
diff --git a/packages/runtime/src/components/ErrorOverview.ts b/packages/runtime/src/components/ErrorOverview.ts
new file mode 100644
index 0000000..0d2fccb
--- /dev/null
+++ b/packages/runtime/src/components/ErrorOverview.ts
@@ -0,0 +1,14 @@
+import { defineComponent, h, type PropType } from "vue";
+
+export const ErrorOverview = defineComponent({
+ name: "ErrorOverview",
+ props: {
+ error: { type: Object as PropType, required: true },
+ },
+ setup(props) {
+ return () =>
+ h("box", { flexDirection: "column" }, [
+ h("text", {}, [props.error.name + ": " + props.error.message]),
+ ]);
+ },
+});
diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts
index 63b41c9..3a5445f 100644
--- a/packages/runtime/src/render.ts
+++ b/packages/runtime/src/render.ts
@@ -1,5 +1,14 @@
import Yoga from "yoga-layout";
-import { type Component, type ComponentPublicInstance, type App as VueApp, shallowRef } from "vue";
+import {
+ type Component,
+ type ComponentPublicInstance,
+ type App as VueApp,
+ defineComponent,
+ h,
+ nextTick,
+ onErrorCaptured,
+ shallowRef,
+} from "vue";
import { createRenderer } from "@vue/runtime-core";
import { EventEmitter } from "node:events";
import { createRoot, type TuiRoot, type TuiNode } from "./host/nodes.ts";
@@ -19,6 +28,7 @@ import {
} from "./context.ts";
import { devState, DevStateKey, initHmrBridge } from "./hmr.ts";
import { createDevOverlayWrapper } from "./overlay.ts";
+import { ErrorOverview } from "./components/ErrorOverview.ts";
export interface MountOptions {
stdout?: NodeJS.WriteStream;
@@ -47,6 +57,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
});
exitPromise.catch(() => {});
+ // Exit-with-error function, wired after mount sets up appContext.
+ // Used by the error boundary to route errors through exit().
+ let exitWithError: (e: Error) => void = () => {};
+
let mountedRoot: TuiRoot | null = null;
let mountedWriter: ReturnType | null = null;
let mountedStdinController: StdinController | null = null;
@@ -108,7 +122,38 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
root = createDevOverlayWrapper(root, rootProps ?? undefined);
rootProps = undefined;
}
- const baseApp = renderer.createApp(root, rootProps ?? undefined);
+
+ // Internal error boundary wrapper: catches all descendant errors (setup,
+ // render, lifecycle) via onErrorCaptured, renders an ErrorOverview frame,
+ // then routes the error through exit(). This prevents yoga WASM corruption
+ // that would occur if errors propagated uncaught during Vue's render phase.
+ const userRoot = root;
+ const userRootProps = rootProps;
+ const ErrorBoundaryRoot = defineComponent({
+ name: "InternalErrorBoundary",
+ setup() {
+ const error = shallowRef(null);
+
+ onErrorCaptured((err) => {
+ const e = err instanceof Error ? err : new Error(String(err));
+ error.value = e;
+ // Flush the ErrorOverview frame, then exit
+ void nextTick(() => {
+ exitWithError(e);
+ });
+ return false; // stop propagation
+ });
+
+ return () => {
+ if (error.value) {
+ return h(ErrorOverview, { error: error.value });
+ }
+ return h(userRoot, userRootProps ?? undefined);
+ };
+ },
+ });
+
+ const baseApp = renderer.createApp(ErrorBoundaryRoot);
const originalMount = baseApp.mount.bind(baseApp);
const originalUnmount = baseApp.unmount.bind(baseApp);
@@ -187,18 +232,14 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
baseApp.provide(DevStateKey, devState);
}
- let proxy: ComponentPublicInstance;
- try {
- proxy = originalMount(tuiRoot) as unknown as ComponentPublicInstance;
- } catch (mountError) {
- stdinController.dispose();
- detachYoga(tuiRoot);
- throw mountError;
- }
+ // Wire exit-with-error for the error boundary (must be set before mount).
+ exitWithError = (e: Error) => appContext.exit(e);
- // errorHandler installed AFTER mount so sync mount errors still throw normally.
- // Async errors (Vue's flushJobs scheduler) get routed through appContext.exit
- // instead of surfacing as unhandled rejections.
+ const proxy = originalMount(tuiRoot) as unknown as ComponentPublicInstance;
+
+ // errorHandler as fallback for errors that bypass onErrorCaptured (e.g.
+ // async errors in Vue's internal scheduler). The error boundary returns
+ // false to stop propagation, so caught errors won't reach here.
baseApp.config.errorHandler = (err) => {
appContext.exit(err instanceof Error ? err : new Error(String(err)));
};
diff --git a/packages/testing/src/render.ts b/packages/testing/src/render.ts
index 0c4f5f0..aaf01ad 100644
--- a/packages/testing/src/render.ts
+++ b/packages/testing/src/render.ts
@@ -69,7 +69,22 @@ export async function render(
trackApp(app);
+ // Attach early-error detector BEFORE flushing, so the rejection handler is
+ // in place when the error boundary's nextTick → exit() → microtask fires.
+ let earlyError: Error | undefined;
+ app.waitUntilExit().catch((e) => {
+ earlyError = e as Error;
+ });
+
+ // Flush the Vue queue. Chain: onErrorCaptured → nextTick → exit → queueMicrotask → reject
await nextTick();
+ await nextTick();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ if (earlyError) {
+ throw earlyError;
+ }
const terminal: Terminal = {
get columns() {