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:
@@ -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