diff --git a/packages/runtime-tests/integration/components/box-in-text-validation.test.tsx b/packages/runtime-tests/integration/components/box-in-text-validation.test.tsx new file mode 100644 index 0000000..7c3487f --- /dev/null +++ b/packages/runtime-tests/integration/components/box-in-text-validation.test.tsx @@ -0,0 +1,20 @@ +import { defineComponent } from "vue"; +import { expect, test, vi } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Box, Text } from "@vue-tui/runtime"; + +test(" inside emits a dev warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await render( + defineComponent(() => () => ( + + invalid + + )), + ); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("")); + + warnSpy.mockRestore(); +}); diff --git a/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx b/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx index ea00065..5416988 100644 --- a/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx @@ -94,6 +94,5 @@ test.todo( "fail when text node is not within component — vue-tui silently allows text-leaf inside box; validation not yet implemented", ); -test.todo( - "fail when is inside component — causes WASM table index out of bounds crash; yoga does not safely reject this nesting", -); +// Resolved: inside now emits a dev warning and skips insertion +// to prevent WASM crash. See box-in-text-validation.test.tsx. diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts index a54e463..2cf2b85 100644 --- a/packages/runtime/src/host/node-ops.ts +++ b/packages/runtime/src/host/node-ops.ts @@ -67,6 +67,16 @@ const STYLE_PROPS = new Set([ "overflowY", ]); +/** Walk up the DOM tree to check if we're inside a text or virtual-text context. */ +function isInsideTextContext(node: TuiContainer): boolean { + let current: TuiContainer | null = node; + while (current) { + if (current.type === "text" || current.type === "virtual-text") return true; + current = current.parent; + } + return false; +} + export function buildNodeOps(options: TtyRendererOptions): RendererOptions { const { onCommit } = options; @@ -132,6 +142,21 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions inside is invalid (matches Ink's validation). + // Inserting a box into a text context corrupts the yoga WASM layout engine. + if ( + process.env["NODE_ENV"] !== "production" && + child.type === "box" && + isInsideTextContext(parentC) + ) { + // eslint-disable-next-line no-console + console.warn( + "[vue-tui] A cannot be nested inside a component. " + + "Wrap it in a sibling instead.", + ); + return; // Skip insertion to prevent WASM crash + } + // Move semantics: if the child is already mounted (Vue's keyed reorder // emits insert(existingChild, parent, newAnchor) without a prior remove), // detach it from its current DOM and yoga positions before re-inserting.