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