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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user