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