diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index e6a9354..2a06ac0 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -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: 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 in 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 | — | — | diff --git a/packages/runtime-tests/integration/layout/prop-reset.test.tsx b/packages/runtime-tests/integration/layout/prop-reset.test.tsx index 34a72d2..313aabb 100644 --- a/packages/runtime-tests/integration/layout/prop-reset.test.tsx +++ b/packages/runtime-tests/integration/layout/prop-reset.test.tsx @@ -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(() => () => ( + + x + + )); + + 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(() => () => ( + + x + + )); + + 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(() => () => ( + + + x + + y + + )); + + 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(() => () => ( + + x + + )); + + 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(() => () => ( + + A + B + + )); + + 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(() => () => ( + + + A + + B + + )); + + 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(() => () => ( + + x + + )); + + 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(() => () => ( + + + X + + Y + + )); + + 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(() => () => ( + + + X + + Y + + )); + + 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(() => () => ( + + + X + + Y + + )); + + 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(() => () => ( + + + A + + B + + )); + + 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"); +}); diff --git a/packages/runtime-tests/unit/yoga-prop-reset.test.ts b/packages/runtime-tests/unit/yoga-prop-reset.test.ts new file mode 100644 index 0000000..b50b90a --- /dev/null +++ b/packages/runtime-tests/unit/yoga-prop-reset.test.ts @@ -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); +}); diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts index 9d11699..9d2b0f7 100644 --- a/packages/runtime/src/host/node-ops.ts +++ b/packages/runtime/src/host/node-ops.ts @@ -245,7 +245,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions string; @@ -262,7 +262,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions }).props[key] = next; diff --git a/packages/runtime/src/host/yoga.ts b/packages/runtime/src/host/yoga.ts index 490d9c9..7564e41 100644 --- a/packages/runtime/src/host/yoga.ts +++ b/packages/runtime/src/host/yoga.ts @@ -137,50 +137,68 @@ export function removeYogaChild(parent: TuiContainer, child: TuiNode): void { const YOGA_PROP_SETTERS: Record 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 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); }