fix(runtime): re-validate text-leaf context in setText (empty anchor can't smuggle bare text into a Box) (#185)

A text-leaf that mounts EMPTY passes insert()'s "must be inside <Text>" guard as
a Vue fragment anchor. If it later becomes non-empty via setText() — e.g.
`<Box><Text>label</Text>{{ maybe }}</Box>` where `maybe` goes ''->'hi' — it was
never re-validated, so non-empty bare text ended up directly under a <Box> and
paint silently DROPPED it (paintNode renders a text-leaf only via a <Text>/
<Transform> 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 <Text>, cleared back to "", or detached is a no-op; the common path
(text inside <Text>) is not rejected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-15 01:21:02 +08:00
committed by GitHub
parent 4243cff937
commit daf5e76dfd
3 changed files with 137 additions and 1 deletions
@@ -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 <Text> component (full)", async () => {
/^Text string "Hello World" must be rendered inside <Text> 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 <Box> and
// paint silently drops it. The sibling interpolation `{{ maybe }}` next to the
// <Text> 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 <Box> rejects (setText path)", async () => {
const maybe = shallowRef("");
const App = defineComponent(() => () => (
<Box>
<Text>label</Text>
{maybe.value}
</Box>
));
// 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 <Text> component$/,
);
});
// Control: the SAME interpolation INSIDE a <Text> 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 <Text> renders and does not throw", async () => {
const maybe = shallowRef("");
const App = defineComponent(() => () => (
<Box>
<Text>{maybe.value}</Text>
</Box>
));
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);
});
+13
View File
@@ -247,6 +247,19 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
// (dom.ts: `if (typeof text !== 'string') text = String(text)`). Guard on
// typeof so normal string values are stored as-is (no double-work).
node.value = typeof text === "string" ? text : String(text);
// An empty text-leaf can mount as a Vue fragment anchor (insert() exempts empty
// leaves), then become non-empty content via setText. Re-validate with the SAME
// rejectsTextLeaf() check insert()/setElementText() use, so non-empty bare text
// directly under a <Box>/root/<Static> 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 <Text>/<Transform> parent). A leaf
// inside a <Text> 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 <Text> 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
+64
View File
@@ -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<typeof createTextLeaf>).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 <Box>. 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<typeof createBox>;
const leaf = ops.createText("") as ReturnType<typeof createTextLeaf>;
// 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 <Text> 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<typeof createBox>;
expect(() => ops.insert(ops.createText("hi"), box, null)).toThrow(
/^Text string "hi" must be rendered inside <Text> component$/,
);
});
test("setText to non-empty inside a tui-text does NOT throw", () => {
// A leaf inside a <Text> 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<typeof createBox>;
const leaf = ops.createText("") as ReturnType<typeof createTextLeaf>;
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<typeof createBox>;
const leaf = ops.createText("") as ReturnType<typeof createTextLeaf>;
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<typeof createTextLeaf>;
expect(leaf.parent).toBe(null);
expect(() => ops.setText(leaf, "hi")).not.toThrow();
expect(leaf.value).toBe("hi");
});