fix(runtime): gate per-edge border width on borderStyle (align Ink) (#168)
Per-edge border props (borderTop/Bottom/Left/Right) reserved 1 yoga cell whenever truthy, regardless of borderStyle. Since these props default to `true`, toggling one on an UPDATE while borderStyle stays unset (Vue patches only the changed per-edge prop, not borderStyle) left a spurious 1-cell inset with no border ever drawn — content shifted to "\n HELLO" instead of "HELLO". Mirror Ink's applyBorderStyles: an edge's width is `borderStyle ? 1 : 0`, forced to 0 when that edge is explicitly `false`. A per-edge toggle can only SUBTRACT, never add. The per-edge yoga setters become no-ops; patchProp now recomputes all four edges from el.props on any border-prop change via the new reconcileBorderEdges helper, so borderStyle flipping in EITHER direction (set->unset zeroes, unset->set re-reserves) and per-edge toggles are all handled jointly — the computation a single (n, v) yoga setter cannot do. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -692,6 +692,115 @@ test("render border edge changes after update when borderStyle is unchanged", as
|
||||
`);
|
||||
});
|
||||
|
||||
// Per-edge border props with NO borderStyle must reserve NO layout space.
|
||||
// box-props.ts defaults borderTop/Bottom/Left/Right to `true`, and box.vue
|
||||
// forwards them via v-bind="props". On an UPDATE that toggles a per-edge prop
|
||||
// while borderStyle stays unset, Vue patches ONLY the changed per-edge prop —
|
||||
// borderStyle is not re-patched — so a per-edge setter that reserves 1 cell
|
||||
// regardless of borderStyle would survive and inset content by 1 cell with no
|
||||
// border ever painted. Ink's applyBorderStyles gates edge width on borderStyle
|
||||
// (borderWidth = currentStyle.borderStyle ? 1 : 0; edge === false ? 0 :
|
||||
// borderWidth) — a per-edge toggle can only SUBTRACT, never add. Match that.
|
||||
test("per-edge border toggle with no borderStyle reserves no space", async ({ expect }) => {
|
||||
const showEdges = shallowRef(false);
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box
|
||||
width={20}
|
||||
borderTop={showEdges.value}
|
||||
borderBottom={showEdges.value}
|
||||
borderLeft={showEdges.value}
|
||||
borderRight={showEdges.value}
|
||||
>
|
||||
<Text>HELLO</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
// No borderStyle → no inset regardless of per-edge flags.
|
||||
expect(stripAnsi(lastFrame()!)).toMatchInlineSnapshot(`"HELLO"`);
|
||||
|
||||
// Toggling the per-edge flags on (with borderStyle still unset) must NOT add
|
||||
// any layout inset — frame stays exactly "HELLO", no spurious blank row /
|
||||
// leading space.
|
||||
showEdges.value = true;
|
||||
await nextTick();
|
||||
expect(stripAnsi(lastFrame()!)).toMatchInlineSnapshot(`"HELLO"`);
|
||||
|
||||
// And toggling back off stays clean too.
|
||||
showEdges.value = false;
|
||||
await nextTick();
|
||||
expect(stripAnsi(lastFrame()!)).toMatchInlineSnapshot(`"HELLO"`);
|
||||
});
|
||||
|
||||
// Regression guard for the gate-on-borderStyle fix: a per-edge `false` toggled
|
||||
// AFTER borderStyle is already set must still SUBTRACT that edge (Ink parity),
|
||||
// and toggling it back on must restore it. borderStyle never changes here, so
|
||||
// only the per-edge prop is re-patched on update — exercising the gated setter's
|
||||
// "borderStyle present → edge reflects v" branch.
|
||||
test("per-edge false with borderStyle set still subtracts on update", async ({ expect }) => {
|
||||
const showTop = shallowRef(true);
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box borderStyle="round" borderTop={showTop.value} alignSelf="flex-start">
|
||||
<Text>Content</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(stripAnsi(lastFrame()!)).toMatchInlineSnapshot(`
|
||||
"╭───────╮
|
||||
│Content│
|
||||
╰───────╯"
|
||||
`);
|
||||
|
||||
showTop.value = false;
|
||||
await nextTick();
|
||||
// borderTop=false subtracts the top edge → no top row, side rails start at row 0.
|
||||
expect(stripAnsi(lastFrame()!)).toMatchInlineSnapshot(`
|
||||
"│Content│
|
||||
╰───────╯"
|
||||
`);
|
||||
|
||||
showTop.value = true;
|
||||
await nextTick();
|
||||
expect(stripAnsi(lastFrame()!)).toMatchInlineSnapshot(`
|
||||
"╭───────╮
|
||||
│Content│
|
||||
╰───────╯"
|
||||
`);
|
||||
});
|
||||
|
||||
// Regression guard: setting borderStyle on an EXISTING box (unset → set) must
|
||||
// reserve all four edges. With the fix re-applying edges on borderStyle change
|
||||
// in EITHER direction, an unset→set transition must add the border back even
|
||||
// though the per-edge props never changed.
|
||||
test("setting borderStyle after mount reserves all edges", async ({ expect }) => {
|
||||
const style = shallowRef<string | undefined>(undefined);
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box width={20} borderStyle={style.value as never}>
|
||||
<Text>HELLO</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
expect(stripAnsi(lastFrame()!)).toMatchInlineSnapshot(`"HELLO"`);
|
||||
|
||||
style.value = "round";
|
||||
await nextTick();
|
||||
expect(stripAnsi(lastFrame()!)).toMatchInlineSnapshot(`
|
||||
"╭──────────────────╮
|
||||
│HELLO │
|
||||
╰──────────────────╯"
|
||||
`);
|
||||
|
||||
// Clearing it again removes the border and the inset.
|
||||
style.value = undefined;
|
||||
await nextTick();
|
||||
expect(stripAnsi(lastFrame()!)).toMatchInlineSnapshot(`"HELLO"`);
|
||||
});
|
||||
|
||||
// hide top border
|
||||
test("hide top border", async ({ expect }) => {
|
||||
const { lastFrame } = await render(
|
||||
|
||||
Reference in New Issue
Block a user