fix(runtime): string flexBasis is a percent, matching Ink (parity) (#88)

A string flexBasis was forwarded to yoga's setFlexBasis, so a bare numeric string like
"3" became 3 absolute cells; Ink coerces ANY string to a percent via Number.parseInt ->
setFlexBasisPercent (styles.ts:547-555). "3" now means 3% (at width 6 the box collapses
to 0 and the sibling takes the row), matching Ink. The setter branch is now structurally
identical to Ink -- number -> absolute, string -> percent, anything else (incl. a
non-number/non-string value Vue's prop validation only warns about) -> setFlexBasisAuto()
instead of throwing.

A separate, pre-existing downstream divergence (zero/negative flexBasis% wraps the
sibling in Ink) is documented with a skipped test -- not caused or fixed here.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 15:30:17 +08:00
committed by GitHub
parent ad067d9ae3
commit c0ebf53e63
3 changed files with 143 additions and 4 deletions
@@ -132,3 +132,106 @@ test('set flex basis in percent with flexDirection="column" container', async ()
);
expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB\n\n");
});
// Ink coerces ANY string flexBasis to a percent (styles.ts:547-555): a bare
// numeric string like "3" is parsed as 3% of the container, NOT 3 absolute
// cells. At width 6, "3" → 3% → 0 cells, so box A collapses and B takes the row.
test("bare numeric-string flexBasis is a percent (Ink parity), not absolute", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box flexBasis="3">
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// 3% of 6 = 0 cells → A width 0 → only B is rendered.
expect(lastFrame({ trimLines: true })).toBe("B");
});
// Guard: a "50%" string still resolves to 50% (3 cells of 6) → "A B".
test('percent-string flexBasis "50%" still resolves as percent', async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box flexBasis="50%">
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame({ trimLines: true })).toBe("A B");
});
// Guard: a numeric flexBasis stays absolute (3 cells) → "A B".
test("numeric flexBasis stays absolute", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box flexBasis={3}>
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame({ trimLines: true })).toBe("A B");
});
// Ink's flexBasis branch (styles.ts:547-555) is number→setFlexBasis,
// string→setFlexBasisPercent, ELSE→setFlexBasisAuto. A non-number/non-string
// runtime value (Vue's [Number,String] prop validation only WARNS — it still
// forwards the value) must fall back to auto, not throw. Ink renders "AB"
// (flexBasis ignored → box shrinks to content). flexBasis={false} is the
// canonical case; the cast bypasses the compile-time prop type to exercise the
// real runtime branch a mis-typed app would hit.
test("non-number/non-string flexBasis falls back to auto (Ink parity), does not throw", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box flexBasis={false as unknown as number}>
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// Auto fallback → box shrinks to content → A and B adjacent, matching Ink.
expect(lastFrame({ trimLines: true })).toBe("AB");
});
// PRE-EXISTING DOWNSTREAM DIVERGENCE — out of scope for the string→percent setter.
// 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);
// vue renders "B" (A dropped). EVIDENCE: with width=6 and this exact tree, the frame is
// input vue-OLD (setFlexBasis(string)) vue-NEW (setFlexBasisPercent) Ink v7.0.4
// "0" "B" "B" "B\nA"
// "-5" "B" "B" "B\nA"
// "0x10" "B" "B" "B\nA"
// vue-OLD already differed from Ink here, so this PR's setter change neither caused nor
// fixed it — the two setters yield byte-identical yoga COMPUTED layout for these inputs;
// the "B" vs "B\nA" gap is a separate downstream paint/wrap divergence. Skipped (not
// xfail-asserted as "B") so we don't lock vue's current behavior as correct: the target
// is Ink's "B\nA". Tracked separately from the flexBasis-percent setter work.
test.skip("zero/negative flexBasis% wraps the sibling in Ink (downstream divergence)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box flexBasis="0">
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA"; vue currently renders "B" (see comment above).
expect(lastFrame({ trimLines: true })).toBe("B\nA");
});
@@ -149,6 +149,30 @@ test("reset flexGrow to 0 on removal (G19)", async () => {
expect(lastFrame({ trimLines: true })).toBe("AB");
});
test("reset flexBasis to auto on removal (G19)", async () => {
// flexBasis="50%" fixes the inner box at 3 cells (50% of 6) so A pads to width 3
// before B; removing flexBasis resets to auto, shrinking the box to content (1 cell).
const hasBasis = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box {...(hasBasis.value ? { flexBasis: "50%" } : {})}>
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
// 50% of 6 = 3 cells → A occupies 3 cols before B
expect(lastFrame({ trimLines: true })).toBe("A B");
hasBasis.value = false;
await nextTick();
// After reset to auto, box shrinks to content; A and B are adjacent
expect(lastFrame({ trimLines: true })).toBe("AB");
});
test("reset justifyContent to flex-start on removal (G19)", async () => {
// justifyContent=flex-end pushes 'x' to the end of a fixed-width row; removing resets to flex-start.
const hasJustify = shallowRef(true);
+16 -4
View File
@@ -190,12 +190,24 @@ const YOGA_PROP_SETTERS: Record<string, (n: YogaNode, v: unknown) => void> = {
flexGrow: (n, v) => n.setFlexGrow(v == null ? 0 : (v as number)),
// Ink default: flexShrink=1 (Box.tsx hardcodes flexShrink:1). Reset to 1 on removal. (G19)
flexShrink: (n, v) => n.setFlexShrink(v == null ? 1 : (v as number)),
// Ink default: flexBasis=auto (yoga default). Reset via setFlexBasisAuto() on removal. (G19)
// Ink default: flexBasis=auto (yoga default), reset via the else branch on removal. (G19)
// Mirror Ink's flexBasis branch exactly (styles.ts:547-555):
// number → setFlexBasis (absolute cells)
// string → setFlexBasisPercent(Number.parseInt(v, 10)) — ANY string is a
// PERCENT, including a bare numeric string like "3" → 3% (NOT 3 cells)
// and "50%" → 50%; yoga would otherwise read "3" as 3 absolute cells.
// else → setFlexBasisAuto() — this is the load-bearing fallback: Vue's
// [Number, String] prop validation only WARNS on a bad runtime value
// (e.g. flexBasis={false}/{}/[]) and still forwards it, so without this
// branch setFlexBasis(false) THROWS where Ink renders fine via auto.
// null/undefined also lands in the else branch → auto (the G19 removal reset).
flexBasis: (n, v) => {
if (v == null) {
n.setFlexBasisAuto();
if (typeof v === "number") {
n.setFlexBasis(v);
} else if (typeof v === "string") {
n.setFlexBasisPercent(Number.parseInt(v, 10));
} else {
n.setFlexBasis(v as number | "auto" | `${number}%`);
n.setFlexBasisAuto();
}
},
// Ink default: flexDirection=row (Box.tsx hardcodes flexDirection:'row'). Reset to ROW on removal. (G19)