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