fix(runtime): reset yoga props to default on dynamic removal (Ink parity, G19) (#48)

* fix(runtime): reset yoga props to default on dynamic removal (Ink parity, G19)

Removed style props now reset to the yoga default (margin/padding/min/gap/flexGrow→0, flexShrink→1, flexBasis→auto, flexDirection→ROW, flexWrap→NO_WRAP, alignItems→STRETCH, alignSelf→AUTO, justifyContent→FLEX_START, position→RELATIVE) instead of keeping a stale value — matches Ink's reconciler diff + styles.ts.

The fix threads the previous prop value (prev) from patchProp into applyYogaProp so that resets only fire on genuine removals (prev is a real value, not null/undefined from Vue's initial-mount or never-set patches). RESETTABLE_PROPS is extended with all newly resettable keys.

Follow-up blocker fixes:
- marginX/marginY/paddingX/paddingY now map to Yoga.EDGE_HORIZONTAL/EDGE_VERTICAL (matching Ink styles.ts) instead of concrete EDGE_START/END/TOP/BOTTOM. They compose with the specific edges per yoga precedence, so removing an axis shorthand no longer clobbers a surviving marginLeft/etc.
- applyYogaProp and all setters now treat null the same as undefined (value == null) for the removal/reset path. Vue's host renderer passes next=null (not undefined) when a key disappears from a spread props object (e.g. Static spreads style into host props), which previously bypassed the reset and forwarded raw null into yoga (NaN/0 corruption).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(parity): ledger — G19 pr-open, reconcile G18 merged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-30 06:31:39 +08:00
committed by GitHub
parent 216a7021a0
commit 2c597e7169
5 changed files with 443 additions and 58 deletions
+2 -2
View File
@@ -52,8 +52,8 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
| G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | merged | `fix/parity-border-1cell` | #37 |
| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | merged | `fix/parity-border-dim` | #44 |
| G17 | render-lifecycle-reconciler | Screen-reader live-path edges: <Static> still grid-painted (Ink linearizes, skipStaticElements:false) + empty SR frame gets a trailing newline (Ink writes wrapped output directly) | P3 | merged | `fix/parity-sr-edges` | #45 |
| G18 | render-lifecycle-reconciler | No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) | P1 | pr-open | `fix/parity-signal-teardown` | #47 |
| G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | todo | — | — |
| G18 | render-lifecycle-reconciler | No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) | P1 | merged | `fix/parity-signal-teardown` | #47 |
| G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | pr-open | `fix/parity-yoga-reset` | #48 |
| G20 | stdout-stderr-stdin-size-cursor | writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) | P2 | todo | — | — |
| G21 | text-wrap-transform | Nested <Transform> in <Text> gets hardcoded index 0 vs child sibling position (squash path) | P3 | todo | — | — |
| G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | todo | — | — |
@@ -26,3 +26,239 @@ test("reset prop when it's removed from the element", async () => {
expect(lastFrame()).toBe("x");
});
// G19: dynamic removal of yoga style props must reset to yoga/Ink default, not keep stale value.
test("reset marginTop to 0 on removal (G19)", async () => {
// marginTop=4 adds 4 blank lines before 'x'; removing it should collapse to no margin.
const hasMargin = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="column" {...(hasMargin.value ? { marginTop: 4 } : {})}>
<Text>x</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
expect(lastFrame({ trimLines: true })).toBe("\n\n\n\nx");
hasMargin.value = false;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("x");
});
test("reset paddingTop to 0 on removal (G19)", async () => {
// paddingTop=3 inside a column box pushes 'x' down 3 rows; removing should collapse.
const hasPadding = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="column" {...(hasPadding.value ? { paddingTop: 3 } : {})}>
<Text>x</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
expect(lastFrame({ trimLines: true })).toBe("\n\n\nx");
hasPadding.value = false;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("x");
});
test("reset minWidth to 0 on removal (G19)", async () => {
// minWidth=10 forces a box to occupy at least 10 columns; removing should shrink to content.
const hasMin = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="row">
<Box {...(hasMin.value ? { minWidth: 10 } : {})}>
<Text>x</Text>
</Box>
<Text>y</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
// With minWidth=10, x occupies 10 cols before y
expect(lastFrame({ trimLines: true })).toBe("x y");
hasMin.value = false;
await nextTick();
// After reset, box shrinks to content; x and y are adjacent
expect(lastFrame({ trimLines: true })).toBe("xy");
});
test("reset minHeight to 0 on removal (G19)", async () => {
// minHeight=4 makes a column box at least 4 rows tall; removing should shrink to content.
const hasMin = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="column" {...(hasMin.value ? { minHeight: 4 } : {})}>
<Text>x</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
// 4 rows: 'x' on first, 3 empty trailing
expect(lastFrame({ trimLines: true })).toBe("x\n\n\n");
hasMin.value = false;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("x");
});
test("reset gap to 0 on removal (G19)", async () => {
// gap=2 in a column box adds 2 blank rows between children; removing should close the gap.
const hasGap = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="column" {...(hasGap.value ? { gap: 2 } : {})}>
<Text>A</Text>
<Text>B</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB");
hasGap.value = false;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("A\nB");
});
test("reset flexGrow to 0 on removal (G19)", async () => {
// flexGrow=1 makes the inner box expand to fill the row; removing it should shrink to content.
const hasGrow = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box {...(hasGrow.value ? { flexGrow: 1 } : {})}>
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
// With flexGrow=1 the first box expands; A appears at the left, B at the right boundary
expect(lastFrame({ trimLines: true })).toBe("A B");
hasGrow.value = false;
await nextTick();
// After reset, both items shrink to content
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);
const Dynamic = defineComponent(() => () => (
<Box
flexDirection="row"
width={4}
{...(hasJustify.value ? { justifyContent: "flex-end" } : {})}
>
<Text>x</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
expect(lastFrame({ trimLines: true })).toBe(" x");
hasJustify.value = false;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("x");
});
// Blocker 1: axis shorthands (marginX/Y, paddingX/Y) must map to the
// HORIZONTAL/VERTICAL yoga axes (like Ink), so they compose with the specific
// edges and removing the axis does not clobber a surviving specific edge.
test("removing marginX preserves a surviving marginLeft (Blocker 1)", async () => {
// Both marginX=2 (horizontal axis) and marginLeft=5 (specific edge) set.
// Per yoga precedence the specific EDGE_START wins on the left, EDGE_HORIZONTAL
// governs the right. Removing marginX must reset only the axis and leave
// marginLeft=5 intact (Vue does not re-emit the unchanged marginLeft).
const hasAxis = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="row">
<Box marginLeft={5} {...(hasAxis.value ? { marginX: 2 } : {})}>
<Text>X</Text>
</Box>
<Text>Y</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
// marginLeft=5 (specific edge wins on left), marginX=2 on the right → 5 + X + 2 + Y
expect(lastFrame({ trimLines: true })).toBe(" X Y");
hasAxis.value = false;
await nextTick();
// Axis reset to 0; surviving marginLeft=5 preserved, right margin gone → 5 + X + Y
expect(lastFrame({ trimLines: true })).toBe(" XY");
});
test("removing paddingX preserves a surviving paddingLeft (Blocker 1)", async () => {
const hasAxis = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="row">
<Box paddingLeft={5} {...(hasAxis.value ? { paddingX: 2 } : {})}>
<Text>X</Text>
</Box>
<Text>Y</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
// paddingLeft=5 on left, paddingX=2 on right → 5 + X + 2 + Y
expect(lastFrame({ trimLines: true })).toBe(" X Y");
hasAxis.value = false;
await nextTick();
// Axis reset to 0; surviving paddingLeft=5 preserved → 5 + X + Y
expect(lastFrame({ trimLines: true })).toBe(" XY");
});
test("marginX composes with marginLeft (specific edge wins) (Blocker 1)", async () => {
// When both set, the specific edge (marginLeft=5) overrides the horizontal
// axis (marginX=2) on the left; the axis still governs the right edge.
const Dynamic = defineComponent(() => () => (
<Box flexDirection="row">
<Box marginX={2} marginLeft={5}>
<Text>X</Text>
</Box>
<Text>Y</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
expect(lastFrame({ trimLines: true })).toBe(" X Y");
});
test("reset position to relative on removal (G19)", async () => {
// position=absolute with offsets removes the box from flow and moves it visually;
// removing 'position' should restore relative positioning (back in flow at top).
const hasAbsolute = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box flexDirection="column" width={4} height={3}>
<Box {...(hasAbsolute.value ? { position: "absolute", top: 2 } : {})}>
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
// With position=absolute + top=2, A is out-of-flow at row 2; B fills row 0
expect(lastFrame({ trimLines: true })).toBe("B\n\nA");
hasAbsolute.value = false;
await nextTick();
// After reset to relative (position prop removed), A re-enters flow above B
expect(lastFrame({ trimLines: true })).toBe("A\nB\n");
});
@@ -0,0 +1,64 @@
import { expect, test } from "vite-plus/test";
// Internal modules not in package exports — import via relative source path,
// matching the convention in unit/animation-scheduler.sequential.test.ts.
// NOTE: yoga-layout is a dependency of @vue-tui/runtime, not of runtime-tests,
// so it cannot be imported here directly. We reference the stable yoga enum
// values numerically (EDGE_LEFT=0, EDGE_TOP=1, DIRECTION_LTR=1) and inspect the
// computed layout via the node's own getComputedMargin/Padding.
import { applyYogaProp, attachYoga, detachYoga } from "../../runtime/src/host/yoga.ts";
import { createBox } from "../../runtime/src/host/nodes.ts";
// yoga-layout YGEnums (generated/YGEnums.ts) — stable values.
const EDGE_LEFT = 0;
const EDGE_TOP = 1;
const DIRECTION_LTR = 1;
// Blocker 2: Vue's HOST renderer passes next=null (not undefined) when a key
// disappears from a spread props object (e.g. Static spreads `style` into host
// props). applyYogaProp's reset path must treat null the same as undefined so a
// removed yoga key resets to its documented default instead of writing NaN/0.
function freshBox() {
const box = createBox();
attachYoga(box);
return box;
}
test("null removal of marginTop resets to default (Blocker 2)", () => {
const box = freshBox();
applyYogaProp(box, "marginTop", 4, undefined);
box.yoga.calculateLayout(undefined, undefined, DIRECTION_LTR as never);
expect(box.yoga.getComputedMargin(EDGE_TOP as never)).toBe(4);
// Removal arrives as next=null (key removed from a spread props object).
applyYogaProp(box, "marginTop", null, 4);
box.yoga.calculateLayout(undefined, undefined, DIRECTION_LTR as never);
expect(box.yoga.getComputedMargin(EDGE_TOP as never)).toBe(0);
detachYoga(box);
});
test("null removal of paddingLeft resets to default (Blocker 2)", () => {
const box = freshBox();
applyYogaProp(box, "paddingLeft", 5, undefined);
box.yoga.calculateLayout(undefined, undefined, DIRECTION_LTR as never);
expect(box.yoga.getComputedPadding(EDGE_LEFT as never)).toBe(5);
applyYogaProp(box, "paddingLeft", null, 5);
box.yoga.calculateLayout(undefined, undefined, DIRECTION_LTR as never);
expect(box.yoga.getComputedPadding(EDGE_LEFT as never)).toBe(0);
detachYoga(box);
});
test("raw null does not corrupt a yoga dimension to NaN (Blocker 2)", () => {
const box = freshBox();
applyYogaProp(box, "marginTop", 7, undefined);
// Removal arrives as null; must reset to 0, never NaN.
applyYogaProp(box, "marginTop", null, 7);
box.yoga.calculateLayout(undefined, undefined, DIRECTION_LTR as never);
const m = box.yoga.getComputedMargin(EDGE_TOP as never);
expect(Number.isNaN(m)).toBe(false);
expect(m).toBe(0);
detachYoga(box);
});
+2 -2
View File
@@ -245,7 +245,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
return (p.children[i + 1] as TuiNode | undefined) ?? null;
}
function patchProp(el: TuiNode, key: string, _prev: unknown, next: unknown): void {
function patchProp(el: TuiNode, key: string, prev: unknown, next: unknown): void {
if (el.type === "transform") {
if (key === "transform" && typeof next === "function") {
el.transform = next as (line: string, idx: number) => string;
@@ -262,7 +262,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
}
if (el.type === "box" || el.type === "text" || el.type === "static" || el.type === "root") {
if (isYogaProp(key)) {
applyYogaProp(el, key, next);
applyYogaProp(el, key, next, prev);
// Some yoga props also need to be stored in el.props for the paint pass.
if (STYLE_PROPS.has(key)) {
(el as { props: Record<string, unknown> }).props[key] = next;
+139 -54
View File
@@ -137,50 +137,68 @@ export function removeYogaChild(parent: TuiContainer, child: TuiNode): void {
const YOGA_PROP_SETTERS: Record<string, (n: YogaNode, v: unknown) => void> = {
width: (n, v) =>
v === undefined ? n.setWidth("auto") : n.setWidth(v as number | "auto" | `${number}%`),
v == null ? n.setWidth("auto") : n.setWidth(v as number | "auto" | `${number}%`),
height: (n, v) =>
v === undefined ? n.setHeight("auto") : n.setHeight(v as number | "auto" | `${number}%`),
minWidth: (n, v) => n.setMinWidth(v as number | `${number}%`),
minHeight: (n, v) => n.setMinHeight(v as number | `${number}%`),
flexGrow: (n, v) => n.setFlexGrow(v as number),
flexShrink: (n, v) => n.setFlexShrink(v as number),
flexBasis: (n, v) => n.setFlexBasis(v as number | "auto" | `${number}%`),
flexDirection: (n, v) => n.setFlexDirection(toFlexDirection(v as string)),
flexWrap: (n, v) => n.setFlexWrap(toFlexWrap(v as string)),
alignItems: (n, v) => n.setAlignItems(toAlign(v as string)),
alignSelf: (n, v) => n.setAlignSelf(toAlign(v as string)),
justifyContent: (n, v) => n.setJustifyContent(toJustify(v as string)),
gap: (n, v) => n.setGap(Yoga.GUTTER_ALL, v as number),
columnGap: (n, v) => n.setGap(Yoga.GUTTER_COLUMN, v as number),
rowGap: (n, v) => n.setGap(Yoga.GUTTER_ROW, v as number),
v == null ? n.setHeight("auto") : n.setHeight(v as number | "auto" | `${number}%`),
// Ink default: minWidth=0 (yoga default). Reset to 0 on removal. (G19)
minWidth: (n, v) => n.setMinWidth(v == null ? 0 : (v as number | `${number}%`)),
// Ink default: minHeight=0 (yoga default). Reset to 0 on removal. (G19)
minHeight: (n, v) => n.setMinHeight(v == null ? 0 : (v as number | `${number}%`)),
// Ink default: flexGrow=0 (Box.tsx hardcodes flexGrow:0). Reset to 0 on removal. (G19)
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)
flexBasis: (n, v) => {
if (v == null) {
n.setFlexBasisAuto();
} else {
n.setFlexBasis(v as number | "auto" | `${number}%`);
}
},
// Ink default: flexDirection=row (Box.tsx hardcodes flexDirection:'row'). Reset to ROW on removal. (G19)
flexDirection: (n, v) =>
n.setFlexDirection(v == null ? Yoga.FLEX_DIRECTION_ROW : toFlexDirection(v as string)),
// Ink default: flexWrap=nowrap (Box.tsx hardcodes flexWrap:'nowrap'). Reset to NO_WRAP on removal. (G19)
flexWrap: (n, v) => n.setFlexWrap(v == null ? Yoga.WRAP_NO_WRAP : toFlexWrap(v as string)),
// Ink default: alignItems=stretch (yoga default). Reset to STRETCH on removal. (G19)
alignItems: (n, v) => n.setAlignItems(v == null ? Yoga.ALIGN_STRETCH : toAlign(v as string)),
// Ink default: alignSelf=auto (yoga default). Reset to AUTO on removal. (G19)
alignSelf: (n, v) => n.setAlignSelf(v == null ? Yoga.ALIGN_AUTO : toAlign(v as string)),
// Ink default: justifyContent=flex-start (yoga default). Reset to FLEX_START on removal. (G19)
justifyContent: (n, v) =>
n.setJustifyContent(v == null ? Yoga.JUSTIFY_FLEX_START : toJustify(v as string)),
// Ink default: gap=0 (yoga default). Reset to 0 on removal. (G19)
gap: (n, v) => n.setGap(Yoga.GUTTER_ALL, v == null ? 0 : (v as number)),
// Ink default: columnGap=0 (yoga default). Reset to 0 on removal. (G19)
columnGap: (n, v) => n.setGap(Yoga.GUTTER_COLUMN, v == null ? 0 : (v as number)),
// Ink default: rowGap=0 (yoga default). Reset to 0 on removal. (G19)
rowGap: (n, v) => n.setGap(Yoga.GUTTER_ROW, v == null ? 0 : (v as number)),
margin: (n, v) => n.setMargin(Yoga.EDGE_ALL, v as number),
marginX: (n, v) => {
n.setMargin(Yoga.EDGE_START, v as number);
n.setMargin(Yoga.EDGE_END, v as number);
},
marginY: (n, v) => {
n.setMargin(Yoga.EDGE_TOP, v as number);
n.setMargin(Yoga.EDGE_BOTTOM, v as number);
},
marginTop: (n, v) => n.setMargin(Yoga.EDGE_TOP, v as number),
marginBottom: (n, v) => n.setMargin(Yoga.EDGE_BOTTOM, v as number),
marginLeft: (n, v) => n.setMargin(Yoga.EDGE_START, v as number),
marginRight: (n, v) => n.setMargin(Yoga.EDGE_END, v as number),
// Ink default: margin=0 (yoga default). Reset all margin edges to 0 on removal. (G19)
margin: (n, v) => n.setMargin(Yoga.EDGE_ALL, v == null ? 0 : (v as number)),
// marginX/marginY map to the HORIZONTAL/VERTICAL axis edges (matching Ink
// styles.ts applyMarginStyles). These compose with the specific edges per
// yoga precedence (EDGE_START/END/TOP/BOTTOM override the axis), so removing
// the axis resets only the axis edge and never clobbers a surviving
// marginLeft/Right/Top/Bottom. (Blocker 1)
marginX: (n, v) => n.setMargin(Yoga.EDGE_HORIZONTAL, v == null ? 0 : (v as number)),
marginY: (n, v) => n.setMargin(Yoga.EDGE_VERTICAL, v == null ? 0 : (v as number)),
marginTop: (n, v) => n.setMargin(Yoga.EDGE_TOP, v == null ? 0 : (v as number)),
marginBottom: (n, v) => n.setMargin(Yoga.EDGE_BOTTOM, v == null ? 0 : (v as number)),
marginLeft: (n, v) => n.setMargin(Yoga.EDGE_START, v == null ? 0 : (v as number)),
marginRight: (n, v) => n.setMargin(Yoga.EDGE_END, v == null ? 0 : (v as number)),
padding: (n, v) => n.setPadding(Yoga.EDGE_ALL, v as number),
paddingX: (n, v) => {
n.setPadding(Yoga.EDGE_LEFT, v as number);
n.setPadding(Yoga.EDGE_RIGHT, v as number);
},
paddingY: (n, v) => {
n.setPadding(Yoga.EDGE_TOP, v as number);
n.setPadding(Yoga.EDGE_BOTTOM, v as number);
},
paddingTop: (n, v) => n.setPadding(Yoga.EDGE_TOP, v as number),
paddingBottom: (n, v) => n.setPadding(Yoga.EDGE_BOTTOM, v as number),
paddingLeft: (n, v) => n.setPadding(Yoga.EDGE_LEFT, v as number),
paddingRight: (n, v) => n.setPadding(Yoga.EDGE_RIGHT, v as number),
// Ink default: padding=0 (yoga default). Reset all padding edges to 0 on removal. (G19)
padding: (n, v) => n.setPadding(Yoga.EDGE_ALL, v == null ? 0 : (v as number)),
// paddingX/paddingY map to the HORIZONTAL/VERTICAL axis edges (matching Ink
// styles.ts applyPaddingStyles); same composition/reset semantics as margin.
paddingX: (n, v) => n.setPadding(Yoga.EDGE_HORIZONTAL, v == null ? 0 : (v as number)),
paddingY: (n, v) => n.setPadding(Yoga.EDGE_VERTICAL, v == null ? 0 : (v as number)),
paddingTop: (n, v) => n.setPadding(Yoga.EDGE_TOP, v == null ? 0 : (v as number)),
paddingBottom: (n, v) => n.setPadding(Yoga.EDGE_BOTTOM, v == null ? 0 : (v as number)),
paddingLeft: (n, v) => n.setPadding(Yoga.EDGE_LEFT, v == null ? 0 : (v as number)),
paddingRight: (n, v) => n.setPadding(Yoga.EDGE_RIGHT, v == null ? 0 : (v as number)),
borderStyle: (n, v) => {
// Border occupies 1 cell on every side when a style is set.
@@ -204,30 +222,29 @@ const YOGA_PROP_SETTERS: Record<string, (n: YogaNode, v: unknown) => void> = {
overflowX: (_n, _v) => {},
overflowY: (_n, _v) => {},
maxWidth: (n, v) =>
v === undefined ? n.setMaxWidth(NaN as never) : n.setMaxWidth(v as number | `${number}%`),
v == null ? n.setMaxWidth(NaN as never) : n.setMaxWidth(v as number | `${number}%`),
maxHeight: (n, v) =>
v === undefined ? n.setMaxHeight(NaN as never) : n.setMaxHeight(v as number | `${number}%`),
v == null ? n.setMaxHeight(NaN as never) : n.setMaxHeight(v as number | `${number}%`),
aspectRatio: (n, v) =>
v === undefined ? n.setAspectRatio(undefined as never) : n.setAspectRatio(v as number),
v == null ? n.setAspectRatio(undefined as never) : n.setAspectRatio(v as number),
alignContent: (n, v) =>
v === undefined
? n.setAlignContent(Yoga.ALIGN_FLEX_START)
: n.setAlignContent(toAlign(v as string)),
position: (n, v) => n.setPositionType(toPosition(v as string)),
v == null ? n.setAlignContent(Yoga.ALIGN_FLEX_START) : n.setAlignContent(toAlign(v as string)),
// Ink default: position=relative (yoga default). Reset to RELATIVE on removal. (G19)
position: (n, v) => n.setPositionType(toPosition(v as string | undefined)),
top: (n, v) =>
v === undefined
v == null
? n.setPosition(Yoga.EDGE_TOP, NaN as never)
: n.setPosition(Yoga.EDGE_TOP, v as number | `${number}%`),
right: (n, v) =>
v === undefined
v == null
? n.setPosition(Yoga.EDGE_RIGHT, NaN as never)
: n.setPosition(Yoga.EDGE_RIGHT, v as number | `${number}%`),
bottom: (n, v) =>
v === undefined
v == null
? n.setPosition(Yoga.EDGE_BOTTOM, NaN as never)
: n.setPosition(Yoga.EDGE_BOTTOM, v as number | `${number}%`),
left: (n, v) =>
v === undefined
v == null
? n.setPosition(Yoga.EDGE_LEFT, NaN as never)
: n.setPosition(Yoga.EDGE_LEFT, v as number | `${number}%`),
};
@@ -285,6 +302,7 @@ export function isYogaProp(key: string): boolean {
}
const RESETTABLE_PROPS = new Set([
// Already handled undefined in their setters (reset to yoga/Ink default on removal):
"width",
"height",
"maxWidth",
@@ -295,9 +313,49 @@ const RESETTABLE_PROPS = new Set([
"right",
"bottom",
"left",
// G19: newly resettable — setters now reset to yoga/Ink default on undefined.
// Defaults: margin/padding/minWidth/minHeight/gap*/columnGap/rowGap → 0;
// flexGrow → 0; flexShrink → 1; flexBasis → auto;
// flexDirection → ROW; flexWrap → NO_WRAP;
// alignItems → STRETCH; alignSelf → AUTO;
// justifyContent → FLEX_START; position → RELATIVE.
// (Matches Ink styles.ts apply blocks + Box.tsx hardcoded defaults.)
"minWidth",
"minHeight",
"flexGrow",
"flexShrink",
"flexBasis",
"flexDirection",
"flexWrap",
"alignItems",
"alignSelf",
"justifyContent",
"gap",
"columnGap",
"rowGap",
"margin",
"marginX",
"marginY",
"marginTop",
"marginBottom",
"marginLeft",
"marginRight",
"padding",
"paddingX",
"paddingY",
"paddingTop",
"paddingBottom",
"paddingLeft",
"paddingRight",
"position",
]);
export function applyYogaProp(node: YogaCarrier, key: string, value: unknown): void {
export function applyYogaProp(
node: YogaCarrier,
key: string,
value: unknown,
prev?: unknown,
): void {
const setter = YOGA_PROP_SETTERS[key];
if (!setter) return;
// Vue calls patchProp with `undefined` for every declared prop a user
@@ -309,7 +367,34 @@ export function applyYogaProp(node: YogaCarrier, key: string, value: unknown): v
// Exception: borderStyle is the one prop with intentional undefined
// semantics — undefined means "no border", which the setter implements
// by zeroing all four edge widths.
if (value === undefined && key !== "borderStyle" && !RESETTABLE_PROPS.has(key)) return;
//
// G19: RESETTABLE_PROPS setters handle undefined by resetting to the yoga/Ink
// default. But we only call them when the prop had a real prior value (prev
// is neither null nor undefined) — this prevents two cases from clobbering
// legitimately-set props:
// 1. Vue calls patchProp(el, key, null, undefined) for every declared prop
// that is absent on the first mount (old=null, new=undefined).
// 2. Vue calls patchProp(el, key, null, undefined) for props absent in a
// shorthand/longhand sibling (e.g. margin=undefined after marginTop=4).
// On actual removal the old value is the previously-set number/string, e.g.
// patchProp(el, 'marginTop', 4, undefined) — prev=4 satisfies the guard.
// This matches Ink's reconciler which only emits undefined for props that
// existed in the old vnode and were dropped from the new one.
//
// Blocker 2: Vue's HOST renderer passes next=null (not undefined) when a key
// disappears from a spread props object (e.g. Static spreads `style` into host
// props, Box forwards). So `value == null` (null OR undefined) is treated as
// removal — forwarding raw null to a yoga dimension setter would write NaN/0
// and corrupt state instead of resetting to the documented default.
if (value == null) {
if (key === "borderStyle") {
// borderStyle: null/undefined always means "no border" — fall through to setter.
} else if (RESETTABLE_PROPS.has(key) && prev !== null && prev !== undefined) {
// Prop was explicitly removed (defined → null/undefined): reset to yoga/Ink default.
} else {
return;
}
}
setter(node.yoga as YogaNode, value);
}