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);
});