fix(runtime): align Ink parity behavior

Align several user-observable runtime behaviors with the Ink v7.0.4 parity audit: live input/paste handler refs, duplicate focus id registration, string-only color props, noninteractive empty final newlines, cross-realm error headers, and contained zero-content box layout/paint.

Document Vue-specific KEEP decisions and require Conventional Commits for commit messages and PR titles.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-08 12:51:20 +08:00
committed by GitHub
parent 1340c049b2
commit 1fd832d297
25 changed files with 971 additions and 332 deletions
@@ -207,13 +207,9 @@ test("non-number/non-string flexBasis falls back to auto (Ink parity), does not
expect(lastFrame({ trimLines: true })).toBe("AB");
});
// A zero/negative parsed percent ("0"→0%, "-5"→-5%, "0x10"→parseInt=0→0%) produces a
// 0-width inner box. Ink renders "B\nA" (B on the row, A wraps onto the next line). The
// 0-width text measures via wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA" → height 2,
// so A occupies a second row. vue previously dropped the text ("B") because wrapText's
// `width <= 0 → [""]` guard collapsed the measure to height 1. Verified against Ink v7.0.4
// (@40b3a75): all four of flexBasis=0/"0%" and width=0/"0%" render "B\nA".
test("zero/negative flexBasis% wraps the sibling in Ink (downstream divergence)", async () => {
// A zero-width inner content rect has no legal child paint area. Children must
// neither paint nor reserve the extra rows Ink's zero-width wrapping creates.
test("zero flexBasis hides children and does not reserve invisible rows", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
@@ -225,11 +221,10 @@ test("zero/negative flexBasis% wraps the sibling in Ink (downstream divergence)"
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA".
expect(lastFrame({ trimLines: true })).toBe("B\nA");
expect(lastFrame({ trimLines: true })).toBe("B");
});
test("zero-width Box wraps its text onto its own line (width={0})", async () => {
test("zero-width Box hides children and does not reserve invisible rows (width={0})", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box width={6}>
@@ -241,12 +236,10 @@ test("zero-width Box wraps its text onto its own line (width={0})", async () =>
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA": the 0-width text measures height 2 via
// wrapAnsi("A", 0, {hard:true}) = "\nA", so A wraps below sibling B.
expect(lastFrame({ trimLines: true })).toBe("B\nA");
expect(lastFrame({ trimLines: true })).toBe("B");
});
test('zero-percent-width Box wraps its text onto its own line (width="0%")', async () => {
test('zero-percent-width Box hides children and does not reserve invisible rows (width="0%")', async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box width={6}>
@@ -258,8 +251,7 @@ test('zero-percent-width Box wraps its text onto its own line (width="0%")', asy
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA" — same as width={0}; a 0% resolved width is also 0px.
expect(lastFrame({ trimLines: true })).toBe("B\nA");
expect(lastFrame({ trimLines: true })).toBe("B");
});
test("zero-width Box with EMPTY text adds no spurious row", async () => {
@@ -279,14 +271,7 @@ test("zero-width Box with EMPTY text adds no spurious row", async () => {
expect(lastFrame({ trimLines: true })).toBe("B");
});
test("zero-width Box with backgroundColor wraps cleanly, keeping the bg glyph (Ink parity)", async () => {
// Regression guard for the wrap-ansi width<=0 byte-split: at width 0 the 0-width Box's
// text wraps onto its own row, but vue bakes the bg color INTO the string before wrapping,
// and wrap-ansi@10 byte-splits the SGR escapes of a STYLED string at width<=0
// (wrapAnsi("\x1b[41mA\x1b[49m", 0) = "\x1b\n[\n4\n1\nm\nA\n…"). That scattered the escape
// bytes across rows and rendered a garbage "B\n[" (the 2nd byte of "\x1b[41m"). wrapText
// now routes width<=0 styled text through an ANSI-aware per-grapheme split, matching Ink,
// which wraps PLAIN text and colorizes per line afterwards.
test("zero-width Box with backgroundColor hides children and does not reserve invisible rows", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
@@ -298,13 +283,22 @@ test("zero-width Box with backgroundColor wraps cleanly, keeping the bg glyph (I
)),
{ columns: 100 },
);
// RAW-byte parity target captured from Ink v7.0.4 (@40b3a75) with chalk level 3:
// "B\n\x1b[41mA\x1b[49m\n" — row 2 keeps the FULL bg-colored glyph (overflow:visible).
// vue trims trailing whitespace/newlines per frame line, so the equivalent raw frame is
// "B\n\x1b[41mA\x1b[49m" (no trailing newline). The bg glyph must survive intact.
expect(lastFrame({ raw: true })).toBe("B\n\x1b[41mA\x1b[49m");
// And the stripped visible layout is "B\nA" (sanity check on the wrap position).
// eslint-disable-next-line no-control-regex -- strip ANSI to assert the visible layout
const visible = lastFrame({ trimLines: true })!.replace(/\x1b\[[0-9;]*m/g, "");
expect(visible).toBe("B\nA");
expect(lastFrame({ raw: true })).toBe("B");
});
test("zero-width Box hides nested Box children and does not reserve invisible rows", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box width={0}>
<Box borderStyle="single">
<Text>A</Text>
</Box>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame({ trimLines: true })).toBe("B");
});
@@ -64,3 +64,55 @@ test("text wraps within border+padding content area", async () => {
expect(frame).toContain("Hello");
expect(frame).toContain("World!");
});
test("children are not painted when border consumes content height", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" width={3} height={2}>
<Text>x</Text>
</Box>
)),
{ columns: 20 },
);
expect(lastFrame()).toBe("┌─┐\n└─┘");
});
test("children are not painted when border consumes content width", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" width={2} height={3}>
<Text>x</Text>
</Box>
)),
{ columns: 20 },
);
expect(lastFrame()).toBe("┌┐\n││\n└┘");
});
test("children are not painted when padding consumes the remaining content area", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" width={4} height={3} paddingX={1}>
<Text>x</Text>
</Box>
)),
{ columns: 20 },
);
expect(lastFrame()).toBe("┌──┐\n│ │\n└──┘");
});
test("children are not painted inside a zero-height side-only border box", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" borderTop={false} borderBottom={false} height={0}>
<Text>x</Text>
</Box>
)),
{ columns: 20 },
);
expect(lastFrame()).toBe("");
});