From daf5e76dfdc92ce20a005b94aa5afef87a3bc21a Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 15 Jun 2026 01:21:02 +0800 Subject: [PATCH] fix(runtime): re-validate text-leaf context in setText (empty anchor can't smuggle bare text into a Box) (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text-leaf that mounts EMPTY passes insert()'s "must be inside " guard as a Vue fragment anchor. If it later becomes non-empty via setText() — e.g. `label{{ maybe }}` where `maybe` goes ''->'hi' — it was never re-validated, so non-empty bare text ended up directly under a and paint silently DROPPED it (paintNode renders a text-leaf only via a / parent). Identical content mounted non-empty throws at insert, so the same content either errored or silently vanished depending on render history. Fix: setText() now re-runs the SAME rejectsTextLeaf() check insert() and setElementText() use (the shared helper added in #179), throwing the same error on an empty->non-empty transition into an invalid context. Throwing in the patch/render phase is consistent with the "validate at render, not paint" invariant and routes through the error boundary (rejects) rather than wedging. A leaf inside , cleared back to "", or detached is a no-op; the common path (text inside ) is not rejected. Co-authored-by: Claude Opus 4.8 (1M context) --- .../box-in-text-validation.test.tsx | 61 +++++++++++++++++- packages/runtime/src/host/node-ops.ts | 13 ++++ packages/runtime/src/host/nodes.test.ts | 64 +++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) 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 index 1ce15c2..b4f90e8 100644 --- a/packages/runtime-tests/integration/components/box-in-text-validation.test.tsx +++ b/packages/runtime-tests/integration/components/box-in-text-validation.test.tsx @@ -1,4 +1,4 @@ -import { defineComponent } from "vue"; +import { defineComponent, nextTick, shallowRef } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; import { Box, Text } from "@vue-tui/runtime"; @@ -35,3 +35,62 @@ test("fail when text node is not within component (full)", async () => { /^Text string "Hello World" must be rendered inside component$/, ); }); + +// A text-leaf that mounts EMPTY (a Vue fragment anchor, which insert() exempts +// from the text-context guard) and LATER becomes non-empty via setText must be +// re-validated — otherwise non-empty bare text ends up directly under a and +// paint silently drops it. The sibling interpolation `{{ maybe }}` next to the +// reaches the host via setText (NOT setElementText, which only fires for a +// single-child Box and is already guarded). Same content, same error as if it had +// mounted non-empty — consistency is the whole point of the fix. +test("sibling interpolation ''->'hi' directly under rejects (setText path)", async () => { + const maybe = shallowRef(""); + const App = defineComponent(() => () => ( + + label + {maybe.value} + + )); + + // Mounts fine: the empty leaf is a skipped fragment anchor. + const { waitUntilExit } = await render(App, { columns: 100 }); + const exited = waitUntilExit(); + + // The reactive update drives setText('', 'hi') on the already-mounted anchor. + // The throw happens during Vue's patch (a host node-op), so vue-tui's error + // boundary routes it through exit() → waitUntilExit() rejects. + maybe.value = "hi"; + await nextTick(); + + await expect(exited).rejects.toThrow( + /^Text string "hi" must be rendered inside component$/, + ); +}); + +// Control: the SAME interpolation INSIDE a is valid inline text. Going +// ''->'hi' must render fine and NOT throw — rejectsTextLeaf() returns false for a +// leaf in a tui-text context, so the new setText re-validation is a no-op here. +test("sibling interpolation ''->'hi' inside renders and does not throw", async () => { + const maybe = shallowRef(""); + const App = defineComponent(() => () => ( + + {maybe.value} + + )); + + const { lastFrame, waitUntilExit } = await render(App, { columns: 100 }); + let rejected = false; + void waitUntilExit().catch(() => { + rejected = true; + }); + expect(lastFrame()).toBe(""); + + maybe.value = "hi"; + await nextTick(); + + expect(lastFrame()).toBe("hi"); + // Give any (incorrect) error-boundary exit a microtask/tick to surface. + await nextTick(); + await Promise.resolve(); + expect(rejected).toBe(false); +}); diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts index 79bee16..0621eb8 100644 --- a/packages/runtime/src/host/node-ops.ts +++ b/packages/runtime/src/host/node-ops.ts @@ -247,6 +247,19 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions/root/ throws HERE (patch/render phase, consistent + // with "validate at render, not paint") instead of silently vanishing at paint + // (paintNode only renders a text-leaf via a / parent). A leaf + // inside a isn't rejected, a leaf cleared back to "" isn't rejected, and a + // detached leaf (parent null) is skipped. isContainer narrows parent to the + // TuiContainer rejectsTextLeaf expects. + const parent = node.parent; + if (parent != null && isContainer(parent) && rejectsTextLeaf(parent, node.value)) { + throw new Error(`Text string "${node.value}" must be rendered inside component`); + } // Bubble dirty up to the node that OWNS the yoga measure func so yoga // remeasures. Mirror Ink's markNodeAsDirty → findClosestYogaNode (dom.ts:248): // climb to the nearest node carrying the MEASURE func and mark it. The catch diff --git a/packages/runtime/src/host/nodes.test.ts b/packages/runtime/src/host/nodes.test.ts index 27c7208..9401574 100644 --- a/packages/runtime/src/host/nodes.test.ts +++ b/packages/runtime/src/host/nodes.test.ts @@ -87,3 +87,67 @@ test("setElementText on a tui-text replaces its children with the text", () => { expect(text.children.length).toBe(1); expect((text.children[0] as ReturnType).value).toBe("hi"); }); + +test("setText re-validates a leaf that mounts empty then becomes non-empty under a box", () => { + // Bug (setText hole): an empty text-leaf mounts as a Vue fragment anchor — + // insert() exempts empty leaves — directly inside a . When it LATER becomes + // non-empty via setText, it must be re-validated with the SAME rejectsTextLeaf + // check insert()/setElementText() use, or non-empty bare text would sit under the + // box and paint would silently drop it. (The same content mounted non-empty + // throws at insert — setText must agree.) + const ops = buildNodeOps({ onCommit: () => {} }); + const box = ops.createElement("tui-box") as ReturnType; + const leaf = ops.createText("") as ReturnType; + + // Empty leaf is exempt → mounts fine as an anchor. + expect(() => ops.insert(leaf, box, null)).not.toThrow(); + expect(box.children.length).toBe(1); + + // Going non-empty must throw the SAME error insert() throws for non-empty text. + expect(() => ops.setText(leaf, "hi")).toThrow( + /^Text string "hi" must be rendered inside component$/, + ); +}); + +test("non-empty text directly under a box still throws at insert (control)", () => { + // Sanity anchor for the bug above: identical content mounted non-empty is + // rejected at insert — so setText going ''->'hi' must reject too. + const ops = buildNodeOps({ onCommit: () => {} }); + const box = ops.createElement("tui-box") as ReturnType; + expect(() => ops.insert(ops.createText("hi"), box, null)).toThrow( + /^Text string "hi" must be rendered inside component$/, + ); +}); + +test("setText to non-empty inside a tui-text does NOT throw", () => { + // A leaf inside a is valid inline text (rejectsTextLeaf returns false for + // a tui-text parent), so the new re-validation must be a no-op here. + const ops = buildNodeOps({ onCommit: () => {} }); + const text = ops.createElement("tui-text") as ReturnType; + const leaf = ops.createText("") as ReturnType; + ops.insert(leaf, text, null); + + expect(() => ops.setText(leaf, "hi")).not.toThrow(); + expect(leaf.value).toBe("hi"); +}); + +test("setText back to empty under a box does NOT throw", () => { + // Clearing a leaf to "" is the fragment-anchor case again — exempt, no throw. + const ops = buildNodeOps({ onCommit: () => {} }); + const box = ops.createElement("tui-box") as ReturnType; + const leaf = ops.createText("") as ReturnType; + ops.insert(leaf, box, null); + + expect(() => ops.setText(leaf, "")).not.toThrow(); + expect(leaf.value).toBe(""); +}); + +test("setText on a detached leaf (no parent) does NOT throw", () => { + // A leaf with parent === null has no context to validate against; the guard + // must skip it rather than crash. + const ops = buildNodeOps({ onCommit: () => {} }); + const leaf = ops.createText("") as ReturnType; + expect(leaf.parent).toBe(null); + expect(() => ops.setText(leaf, "hi")).not.toThrow(); + expect(leaf.value).toBe("hi"); +});