fix(runtime): gate per-edge border width on borderStyle (align Ink) (#168)

Per-edge border props (borderTop/Bottom/Left/Right) reserved 1 yoga cell
whenever truthy, regardless of borderStyle. Since these props default to
`true`, toggling one on an UPDATE while borderStyle stays unset (Vue patches
only the changed per-edge prop, not borderStyle) left a spurious 1-cell inset
with no border ever drawn — content shifted to "\n HELLO" instead of "HELLO".

Mirror Ink's applyBorderStyles: an edge's width is `borderStyle ? 1 : 0`,
forced to 0 when that edge is explicitly `false`. A per-edge toggle can only
SUBTRACT, never add. The per-edge yoga setters become no-ops; patchProp now
recomputes all four edges from el.props on any border-prop change via the new
reconcileBorderEdges helper, so borderStyle flipping in EITHER direction
(set->unset zeroes, unset->set re-reserves) and per-edge toggles are all
handled jointly — the computation a single (n, v) yoga setter cannot do.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-14 18:25:28 +08:00
committed by GitHub
parent f2ef48c91a
commit a2d33d98fe
3 changed files with 165 additions and 31 deletions
+14 -19
View File
@@ -19,6 +19,8 @@ import {
removeYogaChild,
applyYogaProp,
isYogaProp,
BORDER_PROPS,
reconcileBorderEdges,
bindTextMeasure,
markTextDirty,
markTransformDirty,
@@ -392,25 +394,18 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
if (STYLE_PROPS.has(key)) {
(el as { props: Record<string, unknown> }).props[key] = next;
}
// Special case: borderStyle resets all four yoga border-edge widths to
// 1 (or 0). If per-edge toggles (borderTop/Bottom/Left/Right) were
// already applied before this patch, their values were clobbered.
// Re-apply any per-edge toggles that are stored in el.props so that
// yoga reflects the user's explicit per-edge settings.
//
// Only re-apply when `next` is truthy (i.e. a border style is actually
// being set). When borderStyle is cleared/undefined, applyYogaProp sets
// all edges to 0 which is the correct final state — there is nothing to
// restore, and re-applying per-edge defaults (e.g. borderTop:true from
// Box component defaults) would incorrectly reserve border space even
// though no border is drawn.
if (key === "borderStyle" && next) {
const props = (el as { props: Record<string, unknown> }).props;
for (const edge of ["borderTop", "borderBottom", "borderLeft", "borderRight"] as const) {
if (props[edge] !== undefined) {
applyYogaProp(el, edge, props[edge]);
}
}
// Border edge widths depend jointly on borderStyle AND each per-edge prop
// (a yoga setter sees only one value), so any border-prop change triggers a
// full recompute from el.props — mirroring Ink's applyBorderStyles, which
// rewrites all four edges on any border change. The per-edge yoga setters
// are intentional no-ops; this is the single source of truth. Reading
// el.props (just updated above for STYLE_PROPS) means borderStyle flipping
// in EITHER direction is handled: unset→set re-reserves, set→unset zeroes,
// and a per-edge toggle while borderStyle stays unset reserves nothing (so
// the borderTop/Left/etc defaults of `true` never inset content with no
// border drawn).
if (BORDER_PROPS.has(key)) {
reconcileBorderEdges(el, (el as { props: Record<string, unknown> }).props);
}
} else if (STYLE_PROPS.has(key)) {
(el as { props: Record<string, unknown> }).props[key] = next;
+42 -12
View File
@@ -303,18 +303,18 @@ const YOGA_PROP_SETTERS: Record<string, (n: YogaNode, v: unknown) => void> = {
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.
const w = v ? 1 : 0;
n.setBorder(Yoga.EDGE_TOP, w);
n.setBorder(Yoga.EDGE_BOTTOM, w);
n.setBorder(Yoga.EDGE_LEFT, w);
n.setBorder(Yoga.EDGE_RIGHT, w);
},
borderTop: (n, v) => n.setBorder(Yoga.EDGE_TOP, v ? 1 : 0),
borderBottom: (n, v) => n.setBorder(Yoga.EDGE_BOTTOM, v ? 1 : 0),
borderLeft: (n, v) => n.setBorder(Yoga.EDGE_LEFT, v ? 1 : 0),
borderRight: (n, v) => n.setBorder(Yoga.EDGE_RIGHT, v ? 1 : 0),
// borderStyle and the per-edge toggles do NOT compute their own edge widths
// here: an edge's width depends on BOTH borderStyle and that edge's per-edge
// prop together, and a yoga setter only sees one value. patchProp owns the
// joint reconciliation via reconcileBorderEdges (below), which reads the full
// el.props and mirrors Ink's applyBorderStyles. These no-op entries exist only
// so isYogaProp still routes border props through the yoga branch (which also
// stores them into el.props for the paint pass).
borderStyle: () => {},
borderTop: () => {},
borderBottom: () => {},
borderLeft: () => {},
borderRight: () => {},
// Ink styles.ts applyDisplayStyles: `display === 'flex' ? DISPLAY_FLEX : DISPLAY_NONE`,
// so ANY present value that isn't 'flex' (incl. off-spec strings reachable via a TS
@@ -439,6 +439,36 @@ export function isYogaProp(key: string): boolean {
return Object.hasOwn(YOGA_PROP_SETTERS, key);
}
/** Props whose change requires recomputing the yoga border-edge widths. */
export const BORDER_PROPS = new Set([
"borderStyle",
"borderTop",
"borderBottom",
"borderLeft",
"borderRight",
]);
/**
* Recompute all four yoga border-edge widths from a box's full prop set, mirroring
* Ink's applyBorderStyles (styles.ts:729-763): the per-side width is
* `borderStyle ? 1 : 0`, then each edge is forced to 0 when that edge's per-edge
* prop is explicitly `false`. So a per-edge toggle can only SUBTRACT an edge — it
* can NEVER add width without a borderStyle. This is the joint computation a
* single yoga setter cannot do (it sees only one value), and it must run on ANY
* border-prop change, including borderStyle flipping in EITHER direction
* (set→unset re-zeroes, unset→set re-reserves) — otherwise a per-edge toggle made
* while borderStyle stays unset would leave a spurious 1-cell inset with no border
* drawn (the per-edge props default to `true`).
*/
export function reconcileBorderEdges(node: YogaCarrier, props: Record<string, unknown>): void {
const y = node.yoga as YogaNode;
const borderWidth = props["borderStyle"] ? 1 : 0;
y.setBorder(Yoga.EDGE_TOP, props["borderTop"] === false ? 0 : borderWidth);
y.setBorder(Yoga.EDGE_BOTTOM, props["borderBottom"] === false ? 0 : borderWidth);
y.setBorder(Yoga.EDGE_LEFT, props["borderLeft"] === false ? 0 : borderWidth);
y.setBorder(Yoga.EDGE_RIGHT, props["borderRight"] === false ? 0 : borderWidth);
}
const RESETTABLE_PROPS = new Set([
// Already handled undefined in their setters (reset to yoga/Ink default on removal):
"width",