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() {