/** * Pure-TypeScript port of yoga-layout (Meta's flexbox engine). * * This matches the `yoga-layout/load` API surface used by src/ink/layout/yoga.ts. * The upstream C++ source is ~2500 lines in CalculateLayout.cpp alone; this port * is a simplified single-pass flexbox implementation that covers the subset of * features Ink actually uses: * - flex-direction (row/column + reverse) * - flex-grow / flex-shrink / flex-basis * - align-items / align-self (stretch, flex-start, center, flex-end) * - justify-content (all six values) * - margin / padding / border / gap * - width / height / min / max (point, percent, auto) * - position: relative / absolute * - display: flex / none * - measure functions (for text nodes) * * Also implemented for spec parity (not used by Ink): * - margin: auto (main + cross axis, overrides justify/align) * - multi-pass flex clamping when children hit min/max constraints * - flex-grow/shrink against container min/max when size is indefinite * * Also implemented for spec parity (not used by Ink): * - flex-wrap: wrap / wrap-reverse (multi-line flex) * - align-content (positions wrapped lines on cross axis) * * Also implemented for spec parity (not used by Ink): * - display: contents (children lifted to grandparent, box removed) * * Also implemented for spec parity (not used by Ink): * - baseline alignment (align-items/align-self: baseline) * * Not implemented (not used by Ink): * - aspect-ratio * - box-sizing: content-box * - RTL direction (Ink always passes Direction.LTR) * * Upstream: https://github.com/facebook/yoga */ import { Align, BoxSizing, Dimension, Direction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, MeasureMode, Overflow, PositionType, Unit, Wrap, } from './enums.js' export { Align, BoxSizing, Dimension, Direction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, MeasureMode, Overflow, PositionType, Unit, Wrap, } // -- // Value types export type Value = { unit: Unit value: number } const UNDEFINED_VALUE: Value = { unit: Unit.Undefined, value: NaN } const AUTO_VALUE: Value = { unit: Unit.Auto, value: NaN } function pointValue(v: number): Value { return { unit: Unit.Point, value: v } } function percentValue(v: number): Value { return { unit: Unit.Percent, value: v } } function resolveValue(v: Value, ownerSize: number): number { switch (v.unit) { case Unit.Point: return v.value case Unit.Percent: return isNaN(ownerSize) ? NaN : (v.value * ownerSize) / 100 default: return NaN } } function isDefined(n: number): boolean { return !isNaN(n) } // NaN-safe equality for layout-cache input comparison function sameFloat(a: number, b: number): boolean { return a === b || (a !== a && b !== b) } // -- // Layout result (computed values) type Layout = { left: number top: number width: number height: number // Computed per-edge values (resolved to physical edges) border: [number, number, number, number] // left, top, right, bottom padding: [number, number, number, number] margin: [number, number, number, number] } // -- // Style (input values) type Style = { direction: Direction flexDirection: FlexDirection justifyContent: Justify alignItems: Align alignSelf: Align alignContent: Align flexWrap: Wrap overflow: Overflow display: Display positionType: PositionType flexGrow: number flexShrink: number flexBasis: Value // 9-edge arrays indexed by Edge enum margin: Value[] padding: Value[] border: Value[] position: Value[] // 3-gutter array indexed by Gutter enum gap: Value[] width: Value height: Value minWidth: Value minHeight: Value maxWidth: Value maxHeight: Value } function defaultStyle(): Style { return { direction: Direction.Inherit, flexDirection: FlexDirection.Column, justifyContent: Justify.FlexStart, alignItems: Align.Stretch, alignSelf: Align.Auto, alignContent: Align.FlexStart, flexWrap: Wrap.NoWrap, overflow: Overflow.Visible, display: Display.Flex, positionType: PositionType.Relative, flexGrow: 0, flexShrink: 0, flexBasis: AUTO_VALUE, margin: new Array(9).fill(UNDEFINED_VALUE), padding: new Array(9).fill(UNDEFINED_VALUE), border: new Array(9).fill(UNDEFINED_VALUE), position: new Array(9).fill(UNDEFINED_VALUE), gap: new Array(3).fill(UNDEFINED_VALUE), width: AUTO_VALUE, height: AUTO_VALUE, minWidth: UNDEFINED_VALUE, minHeight: UNDEFINED_VALUE, maxWidth: UNDEFINED_VALUE, maxHeight: UNDEFINED_VALUE, } } // -- // Edge resolution — yoga's 9-edge model collapsed to 4 physical edges const EDGE_LEFT = 0 const EDGE_TOP = 1 const EDGE_RIGHT = 2 const EDGE_BOTTOM = 3 function resolveEdge( edges: Value[], physicalEdge: number, ownerSize: number, // For margin/position we allow auto; for padding/border auto resolves to 0 allowAuto = false, ): number { // Precedence: specific edge > horizontal/vertical > all let v = edges[physicalEdge]! if (v.unit === Unit.Undefined) { if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) { v = edges[Edge.Horizontal]! } else { v = edges[Edge.Vertical]! } } if (v.unit === Unit.Undefined) { v = edges[Edge.All]! } // Start/End map to Left/Right for LTR (Ink is always LTR) if (v.unit === Unit.Undefined) { if (physicalEdge === EDGE_LEFT) v = edges[Edge.Start]! if (physicalEdge === EDGE_RIGHT) v = edges[Edge.End]! } if (v.unit === Unit.Undefined) return 0 if (v.unit === Unit.Auto) return allowAuto ? NaN : 0 return resolveValue(v, ownerSize) } function resolveEdgeRaw(edges: Value[], physicalEdge: number): Value { let v = edges[physicalEdge]! if (v.unit === Unit.Undefined) { if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) { v = edges[Edge.Horizontal]! } else { v = edges[Edge.Vertical]! } } if (v.unit === Unit.Undefined) v = edges[Edge.All]! if (v.unit === Unit.Undefined) { if (physicalEdge === EDGE_LEFT) v = edges[Edge.Start]! if (physicalEdge === EDGE_RIGHT) v = edges[Edge.End]! } return v } function isMarginAuto(edges: Value[], physicalEdge: number): boolean { return resolveEdgeRaw(edges, physicalEdge).unit === Unit.Auto } // Setter helpers for the _hasAutoMargin / _hasPosition fast-path flags. // Unit.Undefined = 0, Unit.Auto = 3. function hasAnyAutoEdge(edges: Value[]): boolean { for (let i = 0; i < 9; i++) if (edges[i]!.unit === 3) return true return false } function hasAnyDefinedEdge(edges: Value[]): boolean { for (let i = 0; i < 9; i++) if (edges[i]!.unit !== 0) return true return false } // Hot path: resolve all 4 physical edges in one pass, writing into `out`. // Equivalent to calling resolveEdge() 4× with allowAuto=false, but hoists the // shared fallback lookups (Horizontal/Vertical/All/Start/End) and avoids // allocating a fresh 4-array on every layoutNode() call. function resolveEdges4Into( edges: Value[], ownerSize: number, out: [number, number, number, number], ): void { // Hoist fallbacks once — the 4 per-edge chains share these reads. const eH = edges[6]! // Edge.Horizontal const eV = edges[7]! // Edge.Vertical const eA = edges[8]! // Edge.All const eS = edges[4]! // Edge.Start const eE = edges[5]! // Edge.End const pctDenom = isNaN(ownerSize) ? NaN : ownerSize / 100 // Left: edges[0] → Horizontal → All → Start let v = edges[0]! if (v.unit === 0) v = eH if (v.unit === 0) v = eA if (v.unit === 0) v = eS out[0] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 // Top: edges[1] → Vertical → All v = edges[1]! if (v.unit === 0) v = eV if (v.unit === 0) v = eA out[1] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 // Right: edges[2] → Horizontal → All → End v = edges[2]! if (v.unit === 0) v = eH if (v.unit === 0) v = eA if (v.unit === 0) v = eE out[2] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 // Bottom: edges[3] → Vertical → All v = edges[3]! if (v.unit === 0) v = eV if (v.unit === 0) v = eA out[3] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 } // -- // Axis helpers function isRow(dir: FlexDirection): boolean { return dir === FlexDirection.Row || dir === FlexDirection.RowReverse } function isReverse(dir: FlexDirection): boolean { return dir === FlexDirection.RowReverse || dir === FlexDirection.ColumnReverse } function crossAxis(dir: FlexDirection): FlexDirection { return isRow(dir) ? FlexDirection.Column : FlexDirection.Row } function leadingEdge(dir: FlexDirection): number { switch (dir) { case FlexDirection.Row: return EDGE_LEFT case FlexDirection.RowReverse: return EDGE_RIGHT case FlexDirection.Column: return EDGE_TOP case FlexDirection.ColumnReverse: return EDGE_BOTTOM } } function trailingEdge(dir: FlexDirection): number { switch (dir) { case FlexDirection.Row: return EDGE_RIGHT case FlexDirection.RowReverse: return EDGE_LEFT case FlexDirection.Column: return EDGE_BOTTOM case FlexDirection.ColumnReverse: return EDGE_TOP } } // -- // Public types export type MeasureFunction = ( width: number, widthMode: MeasureMode, height: number, heightMode: MeasureMode, ) => { width: number; height: number } export type Size = { width: number; height: number } // -- // Config export type Config = { pointScaleFactor: number errata: Errata useWebDefaults: boolean free(): void isExperimentalFeatureEnabled(_: ExperimentalFeature): boolean setExperimentalFeatureEnabled(_: ExperimentalFeature, __: boolean): void setPointScaleFactor(factor: number): void getErrata(): Errata setErrata(errata: Errata): void setUseWebDefaults(v: boolean): void } function createConfig(): Config { const config: Config = { pointScaleFactor: 1, errata: Errata.None, useWebDefaults: false, free() {}, isExperimentalFeatureEnabled() { return false }, setExperimentalFeatureEnabled() {}, setPointScaleFactor(f) { config.pointScaleFactor = f }, getErrata() { return config.errata }, setErrata(e) { config.errata = e }, setUseWebDefaults(v) { config.useWebDefaults = v }, } return config } // -- // Node implementation export class Node { style: Style layout: Layout parent: Node | null children: Node[] measureFunc: MeasureFunction | null config: Config isDirty_: boolean isReferenceBaseline_: boolean // Per-layout scratch (not public API) _flexBasis = 0 _mainSize = 0 _crossSize = 0 _lineIndex = 0 // Fast-path flags maintained by style setters. Per CPU profile, the // positioning loop calls isMarginAuto 6× and resolveEdgeRaw(position) 4× // per child per layout pass — ~11k calls for the 1000-node bench, nearly // all of which return false/undefined since most nodes have no auto // margins and no position insets. These flags let us skip straight to // the common case with a single branch. _hasAutoMargin = false _hasPosition = false // Same pattern for the 3× resolveEdges4Into calls at the top of every // layoutNode(). In the 1000-node bench ~67% of those calls operate on // all-undefined edge arrays (most nodes have no border; only cols have // padding; only leaf cells have margin) — a single-branch skip beats // ~20 property reads + ~15 compares + 4 writes of zeros. _hasPadding = false _hasBorder = false _hasMargin = false // -- Dirty-flag layout cache. Mirrors upstream CalculateLayout.cpp's // layoutNodeInternal: skip a subtree entirely when it's clean and we're // asking the same question we cached the answer to. Two slots since // each node typically sees a measure call (performLayout=false, from // computeFlexBasis) followed by a layout call (performLayout=true) with // different inputs per parent pass — a single slot thrashes. Re-layout // bench (dirty one leaf, recompute root) went 2.7x→1.1x with this: // clean siblings skip straight through, only the dirty chain recomputes. _lW = NaN _lH = NaN _lWM: MeasureMode = 0 _lHM: MeasureMode = 0 _lOW = NaN _lOH = NaN _lFW = false _lFH = false // _hasL stores INPUTS early (before compute) but layout.width/height are // mutated by the multi-entry cache and by subsequent compute calls with // different inputs. Without storing OUTPUTS, a _hasL hit returns whatever // layout.width/height happened to be left by the last call — the scrollbox // vpH=33→2624 bug. Store + restore outputs like the multi-entry cache does. _lOutW = NaN _lOutH = NaN _hasL = false _mW = NaN _mH = NaN _mWM: MeasureMode = 0 _mHM: MeasureMode = 0 _mOW = NaN _mOH = NaN _mOutW = NaN _mOutH = NaN _hasM = false // Cached computeFlexBasis result. For clean children, basis only depends // on the container's inner dimensions — if those haven't changed, skip the // layoutNode(performLayout=false) recursion entirely. This is the hot path // for scroll: 500-message content container is dirty, its 499 clean // children each get measured ~20× as the dirty chain's measure/layout // passes cascade. Basis cache short-circuits at the child boundary. _fbBasis = NaN _fbOwnerW = NaN _fbOwnerH = NaN _fbAvailMain = NaN _fbAvailCross = NaN _fbCrossMode: MeasureMode = 0 // Generation at which _fbBasis was written. Dirty nodes from a PREVIOUS // generation have stale cache (subtree changed), but within the SAME // generation the cache is fresh — the dirty chain's measure→layout // cascade invokes computeFlexBasis ≥2^depth times per calculateLayout on // fresh-mounted items, and the subtree doesn't change between calls. // Gating on generation instead of isDirty_ lets fresh mounts (virtual // scroll) cache-hit after first compute: 105k visits → ~10k. _fbGen = -1 // Multi-entry layout cache — stores (inputs → computed w,h) so hits with // different inputs than _hasL can restore the right dimensions. Upstream // yoga uses 16; 4 covers Ink's dirty-chain depth. Packed as flat arrays // to avoid per-entry object allocs. Slot i uses indices [i*8, i*8+8) in // _cIn (aW,aH,wM,hM,oW,oH,fW,fH) and [i*2, i*2+2) in _cOut (w,h). _cIn: Float64Array | null = null _cOut: Float64Array | null = null _cGen = -1 _cN = 0 _cWr = 0 constructor(config?: Config) { this.style = defaultStyle() this.layout = { left: 0, top: 0, width: 0, height: 0, border: [0, 0, 0, 0], padding: [0, 0, 0, 0], margin: [0, 0, 0, 0], } this.parent = null this.children = [] this.measureFunc = null this.config = config ?? DEFAULT_CONFIG this.isDirty_ = true this.isReferenceBaseline_ = false _yogaLiveNodes++ } // -- Tree insertChild(child: Node, index: number): void { child.parent = this this.children.splice(index, 0, child) this.markDirty() } removeChild(child: Node): void { const idx = this.children.indexOf(child) if (idx >= 0) { this.children.splice(idx, 1) child.parent = null this.markDirty() } } getChild(index: number): Node { return this.children[index]! } getChildCount(): number { return this.children.length } getParent(): Node | null { return this.parent } // -- Lifecycle free(): void { this.parent = null this.children = [] this.measureFunc = null this._cIn = null this._cOut = null _yogaLiveNodes-- } freeRecursive(): void { for (const c of this.children) c.freeRecursive() this.free() } reset(): void { this.style = defaultStyle() this.children = [] this.parent = null this.measureFunc = null this.isDirty_ = true this._hasAutoMargin = false this._hasPosition = false this._hasPadding = false this._hasBorder = false this._hasMargin = false this._hasL = false this._hasM = false this._cN = 0 this._cWr = 0 this._fbBasis = NaN } // -- Dirty tracking markDirty(): void { this.isDirty_ = true if (this.parent && !this.parent.isDirty_) this.parent.markDirty() } isDirty(): boolean { return this.isDirty_ } hasNewLayout(): boolean { return true } markLayoutSeen(): void {} // -- Measure function setMeasureFunc(fn: MeasureFunction | null): void { this.measureFunc = fn this.markDirty() } unsetMeasureFunc(): void { this.measureFunc = null this.markDirty() } // -- Computed layout getters getComputedLeft(): number { return this.layout.left } getComputedTop(): number { return this.layout.top } getComputedWidth(): number { return this.layout.width } getComputedHeight(): number { return this.layout.height } getComputedRight(): number { const p = this.parent return p ? p.layout.width - this.layout.left - this.layout.width : 0 } getComputedBottom(): number { const p = this.parent return p ? p.layout.height - this.layout.top - this.layout.height : 0 } getComputedLayout(): { left: number top: number right: number bottom: number width: number height: number } { return { left: this.layout.left, top: this.layout.top, right: this.getComputedRight(), bottom: this.getComputedBottom(), width: this.layout.width, height: this.layout.height, } } getComputedBorder(edge: Edge): number { return this.layout.border[physicalEdge(edge)]! } getComputedPadding(edge: Edge): number { return this.layout.padding[physicalEdge(edge)]! } getComputedMargin(edge: Edge): number { return this.layout.margin[physicalEdge(edge)]! } // -- Style setters: dimensions setWidth(v: number | 'auto' | string | undefined): void { this.style.width = parseDimension(v) this.markDirty() } setWidthPercent(v: number): void { this.style.width = percentValue(v) this.markDirty() } setWidthAuto(): void { this.style.width = AUTO_VALUE this.markDirty() } setHeight(v: number | 'auto' | string | undefined): void { this.style.height = parseDimension(v) this.markDirty() } setHeightPercent(v: number): void { this.style.height = percentValue(v) this.markDirty() } setHeightAuto(): void { this.style.height = AUTO_VALUE this.markDirty() } setMinWidth(v: number | string | undefined): void { this.style.minWidth = parseDimension(v) this.markDirty() } setMinWidthPercent(v: number): void { this.style.minWidth = percentValue(v) this.markDirty() } setMinHeight(v: number | string | undefined): void { this.style.minHeight = parseDimension(v) this.markDirty() } setMinHeightPercent(v: number): void { this.style.minHeight = percentValue(v) this.markDirty() } setMaxWidth(v: number | string | undefined): void { this.style.maxWidth = parseDimension(v) this.markDirty() } setMaxWidthPercent(v: number): void { this.style.maxWidth = percentValue(v) this.markDirty() } setMaxHeight(v: number | string | undefined): void { this.style.maxHeight = parseDimension(v) this.markDirty() } setMaxHeightPercent(v: number): void { this.style.maxHeight = percentValue(v) this.markDirty() } // -- Style setters: flex setFlexDirection(dir: FlexDirection): void { this.style.flexDirection = dir this.markDirty() } setFlexGrow(v: number | undefined): void { this.style.flexGrow = v ?? 0 this.markDirty() } setFlexShrink(v: number | undefined): void { this.style.flexShrink = v ?? 0 this.markDirty() } setFlex(v: number | undefined): void { if (v === undefined || isNaN(v)) { this.style.flexGrow = 0 this.style.flexShrink = 0 } else if (v > 0) { this.style.flexGrow = v this.style.flexShrink = 1 this.style.flexBasis = pointValue(0) } else if (v < 0) { this.style.flexGrow = 0 this.style.flexShrink = -v } else { this.style.flexGrow = 0 this.style.flexShrink = 0 } this.markDirty() } setFlexBasis(v: number | 'auto' | string | undefined): void { this.style.flexBasis = parseDimension(v) this.markDirty() } setFlexBasisPercent(v: number): void { this.style.flexBasis = percentValue(v) this.markDirty() } setFlexBasisAuto(): void { this.style.flexBasis = AUTO_VALUE this.markDirty() } setFlexWrap(wrap: Wrap): void { this.style.flexWrap = wrap this.markDirty() } // -- Style setters: alignment setAlignItems(a: Align): void { this.style.alignItems = a this.markDirty() } setAlignSelf(a: Align): void { this.style.alignSelf = a this.markDirty() } setAlignContent(a: Align): void { this.style.alignContent = a this.markDirty() } setJustifyContent(j: Justify): void { this.style.justifyContent = j this.markDirty() } // -- Style setters: display / position / overflow setDisplay(d: Display): void { this.style.display = d this.markDirty() } getDisplay(): Display { return this.style.display } setPositionType(t: PositionType): void { this.style.positionType = t this.markDirty() } setPosition(edge: Edge, v: number | string | undefined): void { this.style.position[edge] = parseDimension(v) this._hasPosition = hasAnyDefinedEdge(this.style.position) this.markDirty() } setPositionPercent(edge: Edge, v: number): void { this.style.position[edge] = percentValue(v) this._hasPosition = true this.markDirty() } setPositionAuto(edge: Edge): void { this.style.position[edge] = AUTO_VALUE this._hasPosition = true this.markDirty() } setOverflow(o: Overflow): void { this.style.overflow = o this.markDirty() } setDirection(d: Direction): void { this.style.direction = d this.markDirty() } setBoxSizing(_: BoxSizing): void { // Not implemented — Ink doesn't use content-box } // -- Style setters: spacing setMargin(edge: Edge, v: number | 'auto' | string | undefined): void { const val = parseDimension(v) this.style.margin[edge] = val if (val.unit === Unit.Auto) this._hasAutoMargin = true else this._hasAutoMargin = hasAnyAutoEdge(this.style.margin) this._hasMargin = this._hasAutoMargin || hasAnyDefinedEdge(this.style.margin) this.markDirty() } setMarginPercent(edge: Edge, v: number): void { this.style.margin[edge] = percentValue(v) this._hasAutoMargin = hasAnyAutoEdge(this.style.margin) this._hasMargin = true this.markDirty() } setMarginAuto(edge: Edge): void { this.style.margin[edge] = AUTO_VALUE this._hasAutoMargin = true this._hasMargin = true this.markDirty() } setPadding(edge: Edge, v: number | string | undefined): void { this.style.padding[edge] = parseDimension(v) this._hasPadding = hasAnyDefinedEdge(this.style.padding) this.markDirty() } setPaddingPercent(edge: Edge, v: number): void { this.style.padding[edge] = percentValue(v) this._hasPadding = true this.markDirty() } setBorder(edge: Edge, v: number | undefined): void { this.style.border[edge] = v === undefined ? UNDEFINED_VALUE : pointValue(v) this._hasBorder = hasAnyDefinedEdge(this.style.border) this.markDirty() } setGap(gutter: Gutter, v: number | string | undefined): void { this.style.gap[gutter] = parseDimension(v) this.markDirty() } setGapPercent(gutter: Gutter, v: number): void { this.style.gap[gutter] = percentValue(v) this.markDirty() } // -- Style getters (partial — only what tests need) getFlexDirection(): FlexDirection { return this.style.flexDirection } getJustifyContent(): Justify { return this.style.justifyContent } getAlignItems(): Align { return this.style.alignItems } getAlignSelf(): Align { return this.style.alignSelf } getAlignContent(): Align { return this.style.alignContent } getFlexGrow(): number { return this.style.flexGrow } getFlexShrink(): number { return this.style.flexShrink } getFlexBasis(): Value { return this.style.flexBasis } getFlexWrap(): Wrap { return this.style.flexWrap } getWidth(): Value { return this.style.width } getHeight(): Value { return this.style.height } getOverflow(): Overflow { return this.style.overflow } getPositionType(): PositionType { return this.style.positionType } getDirection(): Direction { return this.style.direction } // -- Unused API stubs (present for API parity) copyStyle(_: Node): void {} setDirtiedFunc(_: unknown): void {} unsetDirtiedFunc(): void {} setIsReferenceBaseline(v: boolean): void { this.isReferenceBaseline_ = v this.markDirty() } isReferenceBaseline(): boolean { return this.isReferenceBaseline_ } setAspectRatio(_: number | undefined): void {} getAspectRatio(): number { return NaN } setAlwaysFormsContainingBlock(_: boolean): void {} // -- Layout entry point calculateLayout( ownerWidth: number | undefined, ownerHeight: number | undefined, _direction?: Direction, ): void { _yogaNodesVisited = 0 _yogaMeasureCalls = 0 _yogaCacheHits = 0 _generation++ const w = ownerWidth === undefined ? NaN : ownerWidth const h = ownerHeight === undefined ? NaN : ownerHeight layoutNode( this, w, h, isDefined(w) ? MeasureMode.Exactly : MeasureMode.Undefined, isDefined(h) ? MeasureMode.Exactly : MeasureMode.Undefined, w, h, true, ) // Root's own position = margin + position insets (yoga applies position // to the root even without a parent container; this matters for rounding // since the root's abs top/left seeds the pixel-grid walk). const mar = this.layout.margin const posL = resolveValue( resolveEdgeRaw(this.style.position, EDGE_LEFT), isDefined(w) ? w : 0, ) const posT = resolveValue( resolveEdgeRaw(this.style.position, EDGE_TOP), isDefined(w) ? w : 0, ) this.layout.left = mar[EDGE_LEFT] + (isDefined(posL) ? posL : 0) this.layout.top = mar[EDGE_TOP] + (isDefined(posT) ? posT : 0) roundLayout(this, this.config.pointScaleFactor, 0, 0) } } const DEFAULT_CONFIG = createConfig() const CACHE_SLOTS = 4 function cacheWrite( node: Node, aW: number, aH: number, wM: MeasureMode, hM: MeasureMode, oW: number, oH: number, fW: boolean, fH: boolean, wasDirty: boolean, ): void { if (!node._cIn) { node._cIn = new Float64Array(CACHE_SLOTS * 8) node._cOut = new Float64Array(CACHE_SLOTS * 2) } // First write after a dirty clears stale entries from before the dirty. // _cGen < _generation means entries are from a previous calculateLayout; // if wasDirty, the subtree changed since then → old dimensions invalid. // Clean nodes' old entries stay — same subtree → same result for same // inputs, so cross-generation caching works (the scroll hot path where // 499 clean messages cache-hit while one dirty leaf recomputes). if (wasDirty && node._cGen !== _generation) { node._cN = 0 node._cWr = 0 } // LRU write index wraps; _cN stays at CACHE_SLOTS so the read scan always // checks all populated slots (not just those since last wrap). const i = node._cWr++ % CACHE_SLOTS if (node._cN < CACHE_SLOTS) node._cN = node._cWr const o = i * 8 const cIn = node._cIn cIn[o] = aW cIn[o + 1] = aH cIn[o + 2] = wM cIn[o + 3] = hM cIn[o + 4] = oW cIn[o + 5] = oH cIn[o + 6] = fW ? 1 : 0 cIn[o + 7] = fH ? 1 : 0 node._cOut![i * 2] = node.layout.width node._cOut![i * 2 + 1] = node.layout.height node._cGen = _generation } // Store computed layout.width/height into the single-slot cache output fields. // _hasL/_hasM inputs are committed at the TOP of layoutNode (before compute); // outputs must be committed HERE (after compute) so a cache hit can restore // the correct dimensions. Without this, a _hasL hit returns whatever // layout.width/height was left by the last call — which may be the intrinsic // content height from a heightMode=Undefined measure pass rather than the // constrained viewport height from the layout pass. That's the scrollbox // vpH=33→2624 bug: scrollTop clamps to 0, viewport goes blank. function commitCacheOutputs(node: Node, performLayout: boolean): void { if (performLayout) { node._lOutW = node.layout.width node._lOutH = node.layout.height } else { node._mOutW = node.layout.width node._mOutH = node.layout.height } } // -- // Core flexbox algorithm // Profiling counters — reset per calculateLayout, read via getYogaCounters. // Incremented on each calculateLayout(). Nodes stamp _fbGen/_cGen when // their cache is written; a cache entry with gen === _generation was // computed THIS pass and is fresh regardless of isDirty_ state. let _generation = 0 let _yogaNodesVisited = 0 let _yogaMeasureCalls = 0 let _yogaCacheHits = 0 let _yogaLiveNodes = 0 export function getYogaCounters(): { visited: number measured: number cacheHits: number live: number } { return { visited: _yogaNodesVisited, measured: _yogaMeasureCalls, cacheHits: _yogaCacheHits, live: _yogaLiveNodes, } } function layoutNode( node: Node, availableWidth: number, availableHeight: number, widthMode: MeasureMode, heightMode: MeasureMode, ownerWidth: number, ownerHeight: number, performLayout: boolean, // When true, ignore style dimension on this axis — the flex container // has already determined the main size (flex-basis + grow/shrink result). forceWidth = false, forceHeight = false, ): void { _yogaNodesVisited++ const style = node.style const layout = node.layout // Dirty-flag skip: clean subtree + matching inputs → layout object already // holds the answer. A cached layout result also satisfies a measure request // (positions are a superset of dimensions); the reverse does not hold. // Same-generation entries are fresh regardless of isDirty_ — they were // computed THIS calculateLayout, the subtree hasn't changed since. // Previous-generation entries need !isDirty_ (a dirty node's cache from // before the dirty is stale). // sameGen bypass only for MEASURE calls — a layout-pass cache hit would // skip the child-positioning recursion (STEP 5), leaving children at // stale positions. Measure calls only need w/h which the cache stores. const sameGen = node._cGen === _generation && !performLayout if (!node.isDirty_ || sameGen) { if ( !node.isDirty_ && node._hasL && node._lWM === widthMode && node._lHM === heightMode && node._lFW === forceWidth && node._lFH === forceHeight && sameFloat(node._lW, availableWidth) && sameFloat(node._lH, availableHeight) && sameFloat(node._lOW, ownerWidth) && sameFloat(node._lOH, ownerHeight) ) { _yogaCacheHits++ layout.width = node._lOutW layout.height = node._lOutH return } // Multi-entry cache: scan for matching inputs, restore cached w/h on hit. // Covers the scroll case where a dirty ancestor's measure→layout cascade // produces N>1 distinct input combos per clean child — the single _hasL // slot thrashed, forcing full subtree recursion. With 500-message // scrollbox and one dirty leaf, this took dirty-leaf relayout from // 76k layoutNode calls (21.7×nodes) to 4k (1.2×nodes), 6.86ms → 550µs. // Same-generation check covers fresh-mounted (dirty) nodes during // virtual scroll — the dirty chain invokes them ≥2^depth times, first // call writes cache, rest hit: 105k visits → ~10k for 1593-node tree. if (node._cN > 0 && (sameGen || !node.isDirty_)) { const cIn = node._cIn! for (let i = 0; i < node._cN; i++) { const o = i * 8 if ( cIn[o + 2] === widthMode && cIn[o + 3] === heightMode && cIn[o + 6] === (forceWidth ? 1 : 0) && cIn[o + 7] === (forceHeight ? 1 : 0) && sameFloat(cIn[o]!, availableWidth) && sameFloat(cIn[o + 1]!, availableHeight) && sameFloat(cIn[o + 4]!, ownerWidth) && sameFloat(cIn[o + 5]!, ownerHeight) ) { layout.width = node._cOut![i * 2]! layout.height = node._cOut![i * 2 + 1]! _yogaCacheHits++ return } } } if ( !node.isDirty_ && !performLayout && node._hasM && node._mWM === widthMode && node._mHM === heightMode && sameFloat(node._mW, availableWidth) && sameFloat(node._mH, availableHeight) && sameFloat(node._mOW, ownerWidth) && sameFloat(node._mOH, ownerHeight) ) { layout.width = node._mOutW layout.height = node._mOutH _yogaCacheHits++ return } } // Commit cache inputs up front so every return path leaves a valid entry. // Only clear isDirty_ on the LAYOUT pass — the measure pass (computeFlexBasis // → layoutNode(performLayout=false)) runs before the layout pass in the same // calculateLayout call. Clearing dirty during measure lets the subsequent // layout pass hit the STALE _hasL cache from the previous calculateLayout // (before children were inserted), so ScrollBox content height never grows // and sticky-scroll never follows new content. A dirty node's _hasL entry is // stale by definition — invalidate it so the layout pass recomputes. const wasDirty = node.isDirty_ if (performLayout) { node._lW = availableWidth node._lH = availableHeight node._lWM = widthMode node._lHM = heightMode node._lOW = ownerWidth node._lOH = ownerHeight node._lFW = forceWidth node._lFH = forceHeight node._hasL = true node.isDirty_ = false // Previous approach cleared _cN here to prevent stale pre-dirty entries // from hitting (long-continuous blank-screen bug). Now replaced by // generation stamping: the cache check requires sameGen || !isDirty_, so // previous-generation entries from a dirty node can't hit. Clearing here // would wipe fresh same-generation entries from an earlier measure call, // forcing recompute on the layout call. if (wasDirty) node._hasM = false } else { node._mW = availableWidth node._mH = availableHeight node._mWM = widthMode node._mHM = heightMode node._mOW = ownerWidth node._mOH = ownerHeight node._hasM = true // Don't clear isDirty_. For DIRTY nodes, invalidate _hasL so the upcoming // performLayout=true call recomputes with the new child set (otherwise // sticky-scroll never follows new content — the bug from 4557bc9f9c). // Clean nodes keep _hasL: their layout from the previous generation is // still valid, they're only here because an ancestor is dirty and called // with different inputs than cached. if (wasDirty) node._hasL = false } // Resolve padding/border/margin against ownerWidth (yoga uses ownerWidth for %) // Write directly into the pre-allocated layout arrays — avoids 3 allocs per // layoutNode call and 12 resolveEdge calls (was the #1 hotspot per CPU profile). // Skip entirely when no edges are set — the 4-write zero is cheaper than // the ~20 reads + ~15 compares resolveEdges4Into does to produce zeros. const pad = layout.padding const bor = layout.border const mar = layout.margin if (node._hasPadding) resolveEdges4Into(style.padding, ownerWidth, pad) else pad[0] = pad[1] = pad[2] = pad[3] = 0 if (node._hasBorder) resolveEdges4Into(style.border, ownerWidth, bor) else bor[0] = bor[1] = bor[2] = bor[3] = 0 if (node._hasMargin) resolveEdges4Into(style.margin, ownerWidth, mar) else mar[0] = mar[1] = mar[2] = mar[3] = 0 const paddingBorderWidth = pad[0] + pad[2] + bor[0] + bor[2] const paddingBorderHeight = pad[1] + pad[3] + bor[1] + bor[3] // Resolve style dimensions const styleWidth = forceWidth ? NaN : resolveValue(style.width, ownerWidth) const styleHeight = forceHeight ? NaN : resolveValue(style.height, ownerHeight) // If style dimension is defined, it overrides the available size let width = availableWidth let height = availableHeight let wMode = widthMode let hMode = heightMode if (isDefined(styleWidth)) { width = styleWidth wMode = MeasureMode.Exactly } if (isDefined(styleHeight)) { height = styleHeight hMode = MeasureMode.Exactly } // Apply min/max constraints to the node's own dimensions width = boundAxis(style, true, width, ownerWidth, ownerHeight) height = boundAxis(style, false, height, ownerWidth, ownerHeight) // Measure-func leaf node if (node.measureFunc && node.children.length === 0) { const innerW = wMode === MeasureMode.Undefined ? NaN : Math.max(0, width - paddingBorderWidth) const innerH = hMode === MeasureMode.Undefined ? NaN : Math.max(0, height - paddingBorderHeight) _yogaMeasureCalls++ const measured = node.measureFunc(innerW, wMode, innerH, hMode) node.layout.width = wMode === MeasureMode.Exactly ? width : boundAxis( style, true, (measured.width ?? 0) + paddingBorderWidth, ownerWidth, ownerHeight, ) node.layout.height = hMode === MeasureMode.Exactly ? height : boundAxis( style, false, (measured.height ?? 0) + paddingBorderHeight, ownerWidth, ownerHeight, ) commitCacheOutputs(node, performLayout) // Write cache even for dirty nodes — fresh-mounted items during virtual // scroll are dirty on first layout, but the dirty chain's measure→layout // cascade invokes them ≥2^depth times per calculateLayout. Writing here // lets the 2nd+ calls hit cache (isDirty_ was cleared in the layout pass // above). Measured: 105k visits → 10k for a 1593-node fresh-mount tree. cacheWrite( node, availableWidth, availableHeight, widthMode, heightMode, ownerWidth, ownerHeight, forceWidth, forceHeight, wasDirty, ) return } // Leaf node with no children and no measure func if (node.children.length === 0) { node.layout.width = wMode === MeasureMode.Exactly ? width : boundAxis(style, true, paddingBorderWidth, ownerWidth, ownerHeight) node.layout.height = hMode === MeasureMode.Exactly ? height : boundAxis(style, false, paddingBorderHeight, ownerWidth, ownerHeight) commitCacheOutputs(node, performLayout) // Write cache even for dirty nodes — fresh-mounted items during virtual // scroll are dirty on first layout, but the dirty chain's measure→layout // cascade invokes them ≥2^depth times per calculateLayout. Writing here // lets the 2nd+ calls hit cache (isDirty_ was cleared in the layout pass // above). Measured: 105k visits → 10k for a 1593-node fresh-mount tree. cacheWrite( node, availableWidth, availableHeight, widthMode, heightMode, ownerWidth, ownerHeight, forceWidth, forceHeight, wasDirty, ) return } // Container with children — run flexbox algorithm const mainAxis = style.flexDirection const crossAx = crossAxis(mainAxis) const isMainRow = isRow(mainAxis) const mainSize = isMainRow ? width : height const crossSize = isMainRow ? height : width const mainMode = isMainRow ? wMode : hMode const crossMode = isMainRow ? hMode : wMode const mainPadBorder = isMainRow ? paddingBorderWidth : paddingBorderHeight const crossPadBorder = isMainRow ? paddingBorderHeight : paddingBorderWidth const innerMainSize = isDefined(mainSize) ? Math.max(0, mainSize - mainPadBorder) : NaN const innerCrossSize = isDefined(crossSize) ? Math.max(0, crossSize - crossPadBorder) : NaN // Resolve gap const gapMain = resolveGap( style, isMainRow ? Gutter.Column : Gutter.Row, innerMainSize, ) // Partition children into flow vs absolute. display:contents nodes are // transparent — their children are lifted into the grandparent's child list // (recursively), and the contents node itself gets zero layout. const flowChildren: Node[] = [] const absChildren: Node[] = [] collectLayoutChildren(node, flowChildren, absChildren) // ownerW/H are the reference sizes for resolving children's percentage // values. Per CSS, a % width resolves against the parent's content-box // width. If this node's width is indefinite, children's % widths are also // indefinite — do NOT fall through to the grandparent's size. const ownerW = isDefined(width) ? width : NaN const ownerH = isDefined(height) ? height : NaN const isWrap = style.flexWrap !== Wrap.NoWrap const gapCross = resolveGap( style, isMainRow ? Gutter.Row : Gutter.Column, innerCrossSize, ) // STEP 1: Compute flex-basis for each flow child and break into lines. // Single-line (NoWrap) containers always get one line; multi-line containers // break when accumulated basis+margin+gap exceeds innerMainSize. for (const c of flowChildren) { c._flexBasis = computeFlexBasis( c, mainAxis, innerMainSize, innerCrossSize, crossMode, ownerW, ownerH, ) } const lines: Node[][] = [] if (!isWrap || !isDefined(innerMainSize) || flowChildren.length === 0) { for (const c of flowChildren) c._lineIndex = 0 lines.push(flowChildren) } else { // Line-break decisions use the min/max-clamped basis (flexbox spec §9.3.5: // "hypothetical main size"), not the raw flex-basis. let lineStart = 0 let lineLen = 0 for (let i = 0; i < flowChildren.length; i++) { const c = flowChildren[i]! const hypo = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH) const outer = Math.max(0, hypo) + childMarginForAxis(c, mainAxis, ownerW) const withGap = i > lineStart ? gapMain : 0 if (i > lineStart && lineLen + withGap + outer > innerMainSize) { lines.push(flowChildren.slice(lineStart, i)) lineStart = i lineLen = outer } else { lineLen += withGap + outer } c._lineIndex = lines.length } lines.push(flowChildren.slice(lineStart)) } const lineCount = lines.length const isBaseline = isBaselineLayout(node, flowChildren) // STEP 2+3: For each line, resolve flexible lengths and lay out children to // measure cross sizes. Track per-line consumed main and max cross. const lineConsumedMain: number[] = new Array(lineCount) const lineCrossSizes: number[] = new Array(lineCount) // Baseline layout tracks max ascent (baseline + leading margin) per line so // baseline-aligned items can be positioned at maxAscent - childBaseline. const lineMaxAscent: number[] = isBaseline ? new Array(lineCount).fill(0) : [] let maxLineMain = 0 let totalLinesCross = 0 for (let li = 0; li < lineCount; li++) { const line = lines[li]! const lineGap = line.length > 1 ? gapMain * (line.length - 1) : 0 let lineBasis = lineGap for (const c of line) { lineBasis += c._flexBasis + childMarginForAxis(c, mainAxis, ownerW) } // Resolve flexible lengths against available inner main. For indefinite // containers with min/max, flex against the clamped size. let availMain = innerMainSize if (!isDefined(availMain)) { const mainOwner = isMainRow ? ownerWidth : ownerHeight const minM = resolveValue( isMainRow ? style.minWidth : style.minHeight, mainOwner, ) const maxM = resolveValue( isMainRow ? style.maxWidth : style.maxHeight, mainOwner, ) if (isDefined(maxM) && lineBasis > maxM - mainPadBorder) { availMain = Math.max(0, maxM - mainPadBorder) } else if (isDefined(minM) && lineBasis < minM - mainPadBorder) { availMain = Math.max(0, minM - mainPadBorder) } } resolveFlexibleLengths( line, availMain, lineBasis, isMainRow, ownerW, ownerH, ) // Lay out each child in this line to measure cross let lineCross = 0 for (const c of line) { const cStyle = c.style const childAlign = cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf const cMarginCross = childMarginForAxis(c, crossAx, ownerW) let childCrossSize = NaN let childCrossMode: MeasureMode = MeasureMode.Undefined const resolvedCrossStyle = resolveValue( isMainRow ? cStyle.height : cStyle.width, isMainRow ? ownerH : ownerW, ) const crossLeadE = isMainRow ? EDGE_TOP : EDGE_LEFT const crossTrailE = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT const hasCrossAutoMargin = c._hasAutoMargin && (isMarginAuto(cStyle.margin, crossLeadE) || isMarginAuto(cStyle.margin, crossTrailE)) // Single-line stretch goes directly to the container cross size. // Multi-line wrap measures intrinsic cross (Undefined mode) so // flex-grow grandchildren don't expand to the container — the line // cross size is determined first, then items are re-stretched. if (isDefined(resolvedCrossStyle)) { childCrossSize = resolvedCrossStyle childCrossMode = MeasureMode.Exactly } else if ( childAlign === Align.Stretch && !hasCrossAutoMargin && !isWrap && isDefined(innerCrossSize) && crossMode === MeasureMode.Exactly ) { childCrossSize = Math.max(0, innerCrossSize - cMarginCross) childCrossMode = MeasureMode.Exactly } else if (!isWrap && isDefined(innerCrossSize)) { childCrossSize = Math.max(0, innerCrossSize - cMarginCross) childCrossMode = MeasureMode.AtMost } const cw = isMainRow ? c._mainSize : childCrossSize const ch = isMainRow ? childCrossSize : c._mainSize layoutNode( c, cw, ch, isMainRow ? MeasureMode.Exactly : childCrossMode, isMainRow ? childCrossMode : MeasureMode.Exactly, ownerW, ownerH, performLayout, isMainRow, !isMainRow, ) c._crossSize = isMainRow ? c.layout.height : c.layout.width lineCross = Math.max(lineCross, c._crossSize + cMarginCross) } // Baseline layout: line cross size must fit maxAscent + maxDescent of // baseline-aligned children (yoga STEP 8). Only applies to row direction. if (isBaseline) { let maxAscent = 0 let maxDescent = 0 for (const c of line) { if (resolveChildAlign(node, c) !== Align.Baseline) continue const mTop = resolveEdge(c.style.margin, EDGE_TOP, ownerW) const mBot = resolveEdge(c.style.margin, EDGE_BOTTOM, ownerW) const ascent = calculateBaseline(c) + mTop const descent = c.layout.height + mTop + mBot - ascent if (ascent > maxAscent) maxAscent = ascent if (descent > maxDescent) maxDescent = descent } lineMaxAscent[li] = maxAscent if (maxAscent + maxDescent > lineCross) { lineCross = maxAscent + maxDescent } } // layoutNode(c) at line ~1117 above already resolved c.layout.margin[] via // resolveEdges4Into with the same ownerW — read directly instead of // re-resolving through childMarginForAxis → 2× resolveEdge. const mainLead = leadingEdge(mainAxis) const mainTrail = trailingEdge(mainAxis) let consumed = lineGap for (const c of line) { const cm = c.layout.margin consumed += c._mainSize + cm[mainLead]! + cm[mainTrail]! } lineConsumedMain[li] = consumed lineCrossSizes[li] = lineCross maxLineMain = Math.max(maxLineMain, consumed) totalLinesCross += lineCross } const totalCrossGap = lineCount > 1 ? gapCross * (lineCount - 1) : 0 totalLinesCross += totalCrossGap // STEP 4: Determine container dimensions. Per yoga's STEP 9, for both // AtMost (FitContent) and Undefined (MaxContent) the node sizes to its // content — AtMost is NOT a hard clamp, items may overflow the available // space (CSS "fit-content" behavior). Only Scroll overflow clamps to the // available size. Wrap containers that broke into multiple lines under // AtMost fill the available main size since they wrapped at that boundary. const isScroll = style.overflow === Overflow.Scroll const contentMain = maxLineMain + mainPadBorder const finalMainSize = mainMode === MeasureMode.Exactly ? mainSize : mainMode === MeasureMode.AtMost && isScroll ? Math.max(Math.min(mainSize, contentMain), mainPadBorder) : isWrap && lineCount > 1 && mainMode === MeasureMode.AtMost ? mainSize : contentMain const contentCross = totalLinesCross + crossPadBorder const finalCrossSize = crossMode === MeasureMode.Exactly ? crossSize : crossMode === MeasureMode.AtMost && isScroll ? Math.max(Math.min(crossSize, contentCross), crossPadBorder) : contentCross node.layout.width = boundAxis( style, true, isMainRow ? finalMainSize : finalCrossSize, ownerWidth, ownerHeight, ) node.layout.height = boundAxis( style, false, isMainRow ? finalCrossSize : finalMainSize, ownerWidth, ownerHeight, ) commitCacheOutputs(node, performLayout) // Write cache even for dirty nodes — fresh-mounted items during virtual scroll cacheWrite( node, availableWidth, availableHeight, widthMode, heightMode, ownerWidth, ownerHeight, forceWidth, forceHeight, wasDirty, ) if (!performLayout) return // STEP 5: Position lines (align-content) and children (justify-content + // align-items + auto margins). const actualInnerMain = (isMainRow ? node.layout.width : node.layout.height) - mainPadBorder const actualInnerCross = (isMainRow ? node.layout.height : node.layout.width) - crossPadBorder const mainLeadEdgePhys = leadingEdge(mainAxis) const mainTrailEdgePhys = trailingEdge(mainAxis) const crossLeadEdgePhys = isMainRow ? EDGE_TOP : EDGE_LEFT const crossTrailEdgePhys = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT const reversed = isReverse(mainAxis) const mainContainerSize = isMainRow ? node.layout.width : node.layout.height const crossLead = pad[crossLeadEdgePhys]! + bor[crossLeadEdgePhys]! // Align-content: distribute free cross space among lines. Single-line // containers use the full cross size for the one line (align-items handles // positioning within it). let lineCrossOffset = crossLead let betweenLines = gapCross const freeCross = actualInnerCross - totalLinesCross if (lineCount === 1 && !isWrap && !isBaseline) { lineCrossSizes[0] = actualInnerCross } else { const remCross = Math.max(0, freeCross) switch (style.alignContent) { case Align.FlexStart: break case Align.Center: lineCrossOffset += freeCross / 2 break case Align.FlexEnd: lineCrossOffset += freeCross break case Align.Stretch: if (lineCount > 0 && remCross > 0) { const add = remCross / lineCount for (let i = 0; i < lineCount; i++) lineCrossSizes[i]! += add } break case Align.SpaceBetween: if (lineCount > 1) betweenLines += remCross / (lineCount - 1) break case Align.SpaceAround: if (lineCount > 0) { betweenLines += remCross / lineCount lineCrossOffset += remCross / lineCount / 2 } break case Align.SpaceEvenly: if (lineCount > 0) { betweenLines += remCross / (lineCount + 1) lineCrossOffset += remCross / (lineCount + 1) } break default: break } } // For wrap-reverse, lines stack from the trailing cross edge. Walk lines in // order but flip the cross position within the container. const wrapReverse = style.flexWrap === Wrap.WrapReverse const crossContainerSize = isMainRow ? node.layout.height : node.layout.width let lineCrossPos = lineCrossOffset for (let li = 0; li < lineCount; li++) { const line = lines[li]! const lineCross = lineCrossSizes[li]! const consumedMain = lineConsumedMain[li]! const n = line.length // Re-stretch children whose cross is auto and align is stretch, now that // the line cross size is known. Needed for multi-line wrap (line cross // wasn't known during initial measure) AND single-line when the container // cross was not Exactly (initial stretch at ~line 1250 was skipped because // innerCrossSize wasn't defined — the container sized to max child cross). if (isWrap || crossMode !== MeasureMode.Exactly) { for (const c of line) { const cStyle = c.style const childAlign = cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf const crossStyleDef = isDefined( resolveValue( isMainRow ? cStyle.height : cStyle.width, isMainRow ? ownerH : ownerW, ), ) const hasCrossAutoMargin = c._hasAutoMargin && (isMarginAuto(cStyle.margin, crossLeadEdgePhys) || isMarginAuto(cStyle.margin, crossTrailEdgePhys)) if ( childAlign === Align.Stretch && !crossStyleDef && !hasCrossAutoMargin ) { const cMarginCross = childMarginForAxis(c, crossAx, ownerW) const target = Math.max(0, lineCross - cMarginCross) if (c._crossSize !== target) { const cw = isMainRow ? c._mainSize : target const ch = isMainRow ? target : c._mainSize layoutNode( c, cw, ch, MeasureMode.Exactly, MeasureMode.Exactly, ownerW, ownerH, performLayout, isMainRow, !isMainRow, ) c._crossSize = target } } } } // Justify-content + auto margins for this line let mainOffset = pad[mainLeadEdgePhys]! + bor[mainLeadEdgePhys]! let betweenMain = gapMain let numAutoMarginsMain = 0 for (const c of line) { if (!c._hasAutoMargin) continue if (isMarginAuto(c.style.margin, mainLeadEdgePhys)) numAutoMarginsMain++ if (isMarginAuto(c.style.margin, mainTrailEdgePhys)) numAutoMarginsMain++ } const freeMain = actualInnerMain - consumedMain const remainingMain = Math.max(0, freeMain) const autoMarginMainSize = numAutoMarginsMain > 0 && remainingMain > 0 ? remainingMain / numAutoMarginsMain : 0 if (numAutoMarginsMain === 0) { switch (style.justifyContent) { case Justify.FlexStart: break case Justify.Center: mainOffset += freeMain / 2 break case Justify.FlexEnd: mainOffset += freeMain break case Justify.SpaceBetween: if (n > 1) betweenMain += remainingMain / (n - 1) break case Justify.SpaceAround: if (n > 0) { betweenMain += remainingMain / n mainOffset += remainingMain / n / 2 } break case Justify.SpaceEvenly: if (n > 0) { betweenMain += remainingMain / (n + 1) mainOffset += remainingMain / (n + 1) } break } } const effectiveLineCrossPos = wrapReverse ? crossContainerSize - lineCrossPos - lineCross : lineCrossPos let pos = mainOffset for (const c of line) { const cMargin = c.style.margin // c.layout.margin[] was populated by resolveEdges4Into inside the // layoutNode(c) call above (same ownerW). Read resolved values directly // instead of re-running the edge fallback chain 4× via resolveEdge. // Auto margins resolve to 0 in layout.margin, so autoMarginMainSize // substitution still uses the isMarginAuto check against style. const cLayoutMargin = c.layout.margin let autoMainLead = false let autoMainTrail = false let autoCrossLead = false let autoCrossTrail = false let mMainLead: number let mMainTrail: number let mCrossLead: number let mCrossTrail: number if (c._hasAutoMargin) { autoMainLead = isMarginAuto(cMargin, mainLeadEdgePhys) autoMainTrail = isMarginAuto(cMargin, mainTrailEdgePhys) autoCrossLead = isMarginAuto(cMargin, crossLeadEdgePhys) autoCrossTrail = isMarginAuto(cMargin, crossTrailEdgePhys) mMainLead = autoMainLead ? autoMarginMainSize : cLayoutMargin[mainLeadEdgePhys]! mMainTrail = autoMainTrail ? autoMarginMainSize : cLayoutMargin[mainTrailEdgePhys]! mCrossLead = autoCrossLead ? 0 : cLayoutMargin[crossLeadEdgePhys]! mCrossTrail = autoCrossTrail ? 0 : cLayoutMargin[crossTrailEdgePhys]! } else { // Fast path: no auto margins — read resolved values directly. mMainLead = cLayoutMargin[mainLeadEdgePhys]! mMainTrail = cLayoutMargin[mainTrailEdgePhys]! mCrossLead = cLayoutMargin[crossLeadEdgePhys]! mCrossTrail = cLayoutMargin[crossTrailEdgePhys]! } const mainPos = reversed ? mainContainerSize - (pos + mMainLead) - c._mainSize : pos + mMainLead const childAlign = c.style.alignSelf === Align.Auto ? style.alignItems : c.style.alignSelf let crossPos = effectiveLineCrossPos + mCrossLead const crossFree = lineCross - c._crossSize - mCrossLead - mCrossTrail if (autoCrossLead && autoCrossTrail) { crossPos += Math.max(0, crossFree) / 2 } else if (autoCrossLead) { crossPos += Math.max(0, crossFree) } else if (autoCrossTrail) { // stays at leading } else { switch (childAlign) { case Align.FlexStart: case Align.Stretch: if (wrapReverse) crossPos += crossFree break case Align.Center: crossPos += crossFree / 2 break case Align.FlexEnd: if (!wrapReverse) crossPos += crossFree break case Align.Baseline: // Row direction only (isBaselineLayout checked this). Position so // the child's baseline aligns with the line's max ascent. Per // yoga: top = currentLead + maxAscent - childBaseline + leadingPosition. if (isBaseline) { crossPos = effectiveLineCrossPos + lineMaxAscent[li]! - calculateBaseline(c) } break default: break } } // Relative position offsets. Fast path: no position insets set → // skip 4× resolveEdgeRaw + 4× resolveValue + 4× isDefined. let relX = 0 let relY = 0 if (c._hasPosition) { const relLeft = resolveValue( resolveEdgeRaw(c.style.position, EDGE_LEFT), ownerW, ) const relRight = resolveValue( resolveEdgeRaw(c.style.position, EDGE_RIGHT), ownerW, ) const relTop = resolveValue( resolveEdgeRaw(c.style.position, EDGE_TOP), ownerW, ) const relBottom = resolveValue( resolveEdgeRaw(c.style.position, EDGE_BOTTOM), ownerW, ) relX = isDefined(relLeft) ? relLeft : isDefined(relRight) ? -relRight : 0 relY = isDefined(relTop) ? relTop : isDefined(relBottom) ? -relBottom : 0 } if (isMainRow) { c.layout.left = mainPos + relX c.layout.top = crossPos + relY } else { c.layout.left = crossPos + relX c.layout.top = mainPos + relY } pos += c._mainSize + mMainLead + mMainTrail + betweenMain } lineCrossPos += lineCross + betweenLines } // STEP 6: Absolute-positioned children for (const c of absChildren) { layoutAbsoluteChild( node, c, node.layout.width, node.layout.height, pad, bor, ) } } function layoutAbsoluteChild( parent: Node, child: Node, parentWidth: number, parentHeight: number, pad: [number, number, number, number], bor: [number, number, number, number], ): void { const cs = child.style const posLeft = resolveEdgeRaw(cs.position, EDGE_LEFT) const posRight = resolveEdgeRaw(cs.position, EDGE_RIGHT) const posTop = resolveEdgeRaw(cs.position, EDGE_TOP) const posBottom = resolveEdgeRaw(cs.position, EDGE_BOTTOM) const rLeft = resolveValue(posLeft, parentWidth) const rRight = resolveValue(posRight, parentWidth) const rTop = resolveValue(posTop, parentHeight) const rBottom = resolveValue(posBottom, parentHeight) // Absolute children's percentage dimensions resolve against the containing // block's padding-box (parent size minus border), per CSS §10.1. const paddingBoxW = parentWidth - bor[0] - bor[2] const paddingBoxH = parentHeight - bor[1] - bor[3] let cw = resolveValue(cs.width, paddingBoxW) let ch = resolveValue(cs.height, paddingBoxH) // If both left+right defined and width not, derive width if (!isDefined(cw) && isDefined(rLeft) && isDefined(rRight)) { cw = paddingBoxW - rLeft - rRight } if (!isDefined(ch) && isDefined(rTop) && isDefined(rBottom)) { ch = paddingBoxH - rTop - rBottom } layoutNode( child, cw, ch, isDefined(cw) ? MeasureMode.Exactly : MeasureMode.Undefined, isDefined(ch) ? MeasureMode.Exactly : MeasureMode.Undefined, paddingBoxW, paddingBoxH, true, ) // Margin of absolute child (applied in addition to insets) const mL = resolveEdge(cs.margin, EDGE_LEFT, parentWidth) const mT = resolveEdge(cs.margin, EDGE_TOP, parentWidth) const mR = resolveEdge(cs.margin, EDGE_RIGHT, parentWidth) const mB = resolveEdge(cs.margin, EDGE_BOTTOM, parentWidth) const mainAxis = parent.style.flexDirection const reversed = isReverse(mainAxis) const mainRow = isRow(mainAxis) const wrapReverse = parent.style.flexWrap === Wrap.WrapReverse // alignSelf overrides alignItems for absolute children (same as flow items) const alignment = cs.alignSelf === Align.Auto ? parent.style.alignItems : cs.alignSelf // Position let left: number if (isDefined(rLeft)) { left = bor[0] + rLeft + mL } else if (isDefined(rRight)) { left = parentWidth - bor[2] - rRight - child.layout.width - mR } else if (mainRow) { // Main axis — justify-content, flipped for reversed const lead = pad[0] + bor[0] const trail = parentWidth - pad[2] - bor[2] left = reversed ? trail - child.layout.width - mR : justifyAbsolute( parent.style.justifyContent, lead, trail, child.layout.width, ) + mL } else { left = alignAbsolute( alignment, pad[0] + bor[0], parentWidth - pad[2] - bor[2], child.layout.width, wrapReverse, ) + mL } let top: number if (isDefined(rTop)) { top = bor[1] + rTop + mT } else if (isDefined(rBottom)) { top = parentHeight - bor[3] - rBottom - child.layout.height - mB } else if (mainRow) { top = alignAbsolute( alignment, pad[1] + bor[1], parentHeight - pad[3] - bor[3], child.layout.height, wrapReverse, ) + mT } else { const lead = pad[1] + bor[1] const trail = parentHeight - pad[3] - bor[3] top = reversed ? trail - child.layout.height - mB : justifyAbsolute( parent.style.justifyContent, lead, trail, child.layout.height, ) + mT } child.layout.left = left child.layout.top = top } function justifyAbsolute( justify: Justify, leadEdge: number, trailEdge: number, childSize: number, ): number { switch (justify) { case Justify.Center: return leadEdge + (trailEdge - leadEdge - childSize) / 2 case Justify.FlexEnd: return trailEdge - childSize default: return leadEdge } } function alignAbsolute( align: Align, leadEdge: number, trailEdge: number, childSize: number, wrapReverse: boolean, ): number { // Wrap-reverse flips the cross axis: flex-start/stretch go to trailing, // flex-end goes to leading (yoga's absoluteLayoutChild flips the align value // when the containing block has wrap-reverse). switch (align) { case Align.Center: return leadEdge + (trailEdge - leadEdge - childSize) / 2 case Align.FlexEnd: return wrapReverse ? leadEdge : trailEdge - childSize default: return wrapReverse ? trailEdge - childSize : leadEdge } } function computeFlexBasis( child: Node, mainAxis: FlexDirection, availableMain: number, availableCross: number, crossMode: MeasureMode, ownerWidth: number, ownerHeight: number, ): number { // Same-generation cache hit: basis was computed THIS calculateLayout, so // it's fresh regardless of isDirty_. Covers both clean children (scrolling // past unchanged messages) AND fresh-mounted dirty children (virtual // scroll mounts new items — the dirty chain's measure→layout cascade // invokes this ≥2^depth times, but the child's subtree doesn't change // between calls within one calculateLayout). For clean children with // cache from a PREVIOUS generation, also hit if inputs match — isDirty_ // gates since a dirty child's previous-gen cache is stale. const sameGen = child._fbGen === _generation if ( (sameGen || !child.isDirty_) && child._fbCrossMode === crossMode && sameFloat(child._fbOwnerW, ownerWidth) && sameFloat(child._fbOwnerH, ownerHeight) && sameFloat(child._fbAvailMain, availableMain) && sameFloat(child._fbAvailCross, availableCross) ) { return child._fbBasis } const cs = child.style const isMainRow = isRow(mainAxis) // Explicit flex-basis const basis = resolveValue(cs.flexBasis, availableMain) if (isDefined(basis)) { const b = Math.max(0, basis) child._fbBasis = b child._fbOwnerW = ownerWidth child._fbOwnerH = ownerHeight child._fbAvailMain = availableMain child._fbAvailCross = availableCross child._fbCrossMode = crossMode child._fbGen = _generation return b } // Style dimension on main axis const mainStyleDim = isMainRow ? cs.width : cs.height const mainOwner = isMainRow ? ownerWidth : ownerHeight const resolved = resolveValue(mainStyleDim, mainOwner) if (isDefined(resolved)) { const b = Math.max(0, resolved) child._fbBasis = b child._fbOwnerW = ownerWidth child._fbOwnerH = ownerHeight child._fbAvailMain = availableMain child._fbAvailCross = availableCross child._fbCrossMode = crossMode child._fbGen = _generation return b } // Need to measure the child to get its natural size const crossStyleDim = isMainRow ? cs.height : cs.width const crossOwner = isMainRow ? ownerHeight : ownerWidth let crossConstraint = resolveValue(crossStyleDim, crossOwner) let crossConstraintMode: MeasureMode = isDefined(crossConstraint) ? MeasureMode.Exactly : MeasureMode.Undefined if (!isDefined(crossConstraint) && isDefined(availableCross)) { crossConstraint = availableCross crossConstraintMode = crossMode === MeasureMode.Exactly && isStretchAlign(child) ? MeasureMode.Exactly : MeasureMode.AtMost } // Upstream yoga (YGNodeComputeFlexBasisForChild) passes the available inner // width with mode AtMost when the subtree will call a measure-func — so text // nodes don't report unconstrained intrinsic width as flex-basis, which // would force siblings to shrink and the text to wrap at the wrong width. // Passing Undefined here made Ink's inside get // width = intrinsic instead of available, dropping chars at wrap boundaries. // // Two constraints on when this applies: // - Width only. Height is never constrained during basis measurement — // column containers must measure children at natural height so // scrollable content can overflow (constraining height clips ScrollBox). // - Subtree has a measure-func. Pure layout subtrees (no measure-func) // with flex-grow children would grow into the AtMost constraint, // inflating the basis (breaks YGMinMaxDimensionTest flex_grow_in_at_most // where a flexGrow:1 child should stay at basis 0, not grow to 100). let mainConstraint = NaN let mainConstraintMode: MeasureMode = MeasureMode.Undefined if (isMainRow && isDefined(availableMain) && hasMeasureFuncInSubtree(child)) { mainConstraint = availableMain mainConstraintMode = MeasureMode.AtMost } const mw = isMainRow ? mainConstraint : crossConstraint const mh = isMainRow ? crossConstraint : mainConstraint const mwMode = isMainRow ? mainConstraintMode : crossConstraintMode const mhMode = isMainRow ? crossConstraintMode : mainConstraintMode layoutNode(child, mw, mh, mwMode, mhMode, ownerWidth, ownerHeight, false) const b = isMainRow ? child.layout.width : child.layout.height child._fbBasis = b child._fbOwnerW = ownerWidth child._fbOwnerH = ownerHeight child._fbAvailMain = availableMain child._fbAvailCross = availableCross child._fbCrossMode = crossMode child._fbGen = _generation return b } function hasMeasureFuncInSubtree(node: Node): boolean { if (node.measureFunc) return true for (const c of node.children) { if (hasMeasureFuncInSubtree(c)) return true } return false } function resolveFlexibleLengths( children: Node[], availableInnerMain: number, totalFlexBasis: number, isMainRow: boolean, ownerW: number, ownerH: number, ): void { // Multi-pass flex distribution per CSS flexbox spec §9.7 "Resolving Flexible // Lengths": distribute free space, detect min/max violations, freeze all // violators, redistribute among unfrozen children. Repeat until stable. const n = children.length const frozen: boolean[] = new Array(n).fill(false) const initialFree = isDefined(availableInnerMain) ? availableInnerMain - totalFlexBasis : 0 // Freeze inflexible items at their clamped basis for (let i = 0; i < n; i++) { const c = children[i]! const clamped = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH) const inflexible = !isDefined(availableInnerMain) || (initialFree >= 0 ? c.style.flexGrow === 0 : c.style.flexShrink === 0) if (inflexible) { c._mainSize = Math.max(0, clamped) frozen[i] = true } else { c._mainSize = c._flexBasis } } // Iteratively distribute until no violations. Free space is recomputed each // pass: initial free space minus the delta frozen children consumed beyond // (or below) their basis. const unclamped: number[] = new Array(n) for (let iter = 0; iter <= n; iter++) { let frozenDelta = 0 let totalGrow = 0 let totalShrinkScaled = 0 let unfrozenCount = 0 for (let i = 0; i < n; i++) { const c = children[i]! if (frozen[i]) { frozenDelta += c._mainSize - c._flexBasis } else { totalGrow += c.style.flexGrow totalShrinkScaled += c.style.flexShrink * c._flexBasis unfrozenCount++ } } if (unfrozenCount === 0) break let remaining = initialFree - frozenDelta // Spec §9.7 step 4c: if sum of flex factors < 1, only distribute // initialFree × sum, not the full remaining space (partial flex). if (remaining > 0 && totalGrow > 0 && totalGrow < 1) { const scaled = initialFree * totalGrow if (scaled < remaining) remaining = scaled } else if (remaining < 0 && totalShrinkScaled > 0) { let totalShrink = 0 for (let i = 0; i < n; i++) { if (!frozen[i]) totalShrink += children[i]!.style.flexShrink } if (totalShrink < 1) { const scaled = initialFree * totalShrink if (scaled > remaining) remaining = scaled } } // Compute targets + violations for all unfrozen children let totalViolation = 0 for (let i = 0; i < n; i++) { if (frozen[i]) continue const c = children[i]! let t = c._flexBasis if (remaining > 0 && totalGrow > 0) { t += (remaining * c.style.flexGrow) / totalGrow } else if (remaining < 0 && totalShrinkScaled > 0) { t += (remaining * (c.style.flexShrink * c._flexBasis)) / totalShrinkScaled } unclamped[i] = t const clamped = Math.max( 0, boundAxis(c.style, isMainRow, t, ownerW, ownerH), ) c._mainSize = clamped totalViolation += clamped - t } // Freeze per spec §9.7 step 5: if totalViolation is zero freeze all; if // positive freeze min-violators; if negative freeze max-violators. if (totalViolation === 0) break let anyFrozen = false for (let i = 0; i < n; i++) { if (frozen[i]) continue const v = children[i]!._mainSize - unclamped[i]! if ((totalViolation > 0 && v > 0) || (totalViolation < 0 && v < 0)) { frozen[i] = true anyFrozen = true } } if (!anyFrozen) break } } function isStretchAlign(child: Node): boolean { const p = child.parent if (!p) return false const align = child.style.alignSelf === Align.Auto ? p.style.alignItems : child.style.alignSelf return align === Align.Stretch } function resolveChildAlign(parent: Node, child: Node): Align { return child.style.alignSelf === Align.Auto ? parent.style.alignItems : child.style.alignSelf } // Baseline of a node per CSS Flexbox §8.5 / yoga's YGBaseline. Leaf nodes // (no children) use their own height. Containers recurse into the first // baseline-aligned child on the first line (or the first flow child if none // are baseline-aligned), returning that child's baseline + its top offset. function calculateBaseline(node: Node): number { let baselineChild: Node | null = null for (const c of node.children) { if (c._lineIndex > 0) break if (c.style.positionType === PositionType.Absolute) continue if (c.style.display === Display.None) continue if ( resolveChildAlign(node, c) === Align.Baseline || c.isReferenceBaseline_ ) { baselineChild = c break } if (baselineChild === null) baselineChild = c } if (baselineChild === null) return node.layout.height return calculateBaseline(baselineChild) + baselineChild.layout.top } // A container uses baseline layout only for row direction, when either // align-items is baseline or any flow child has align-self: baseline. function isBaselineLayout(node: Node, flowChildren: Node[]): boolean { if (!isRow(node.style.flexDirection)) return false if (node.style.alignItems === Align.Baseline) return true for (const c of flowChildren) { if (c.style.alignSelf === Align.Baseline) return true } return false } function childMarginForAxis( child: Node, axis: FlexDirection, ownerWidth: number, ): number { if (!child._hasMargin) return 0 const lead = resolveEdge(child.style.margin, leadingEdge(axis), ownerWidth) const trail = resolveEdge(child.style.margin, trailingEdge(axis), ownerWidth) return lead + trail } function resolveGap(style: Style, gutter: Gutter, ownerSize: number): number { let v = style.gap[gutter]! if (v.unit === Unit.Undefined) v = style.gap[Gutter.All]! const r = resolveValue(v, ownerSize) return isDefined(r) ? Math.max(0, r) : 0 } function boundAxis( style: Style, isWidth: boolean, value: number, ownerWidth: number, ownerHeight: number, ): number { const minV = isWidth ? style.minWidth : style.minHeight const maxV = isWidth ? style.maxWidth : style.maxHeight const minU = minV.unit const maxU = maxV.unit // Fast path: no min/max constraints set. Per CPU profile this is the // overwhelmingly common case (~32k calls/layout on the 1000-node bench, // nearly all with undefined min/max) — skipping 2× resolveValue + 2× isNaN // that always no-op. Unit.Undefined = 0. if (minU === 0 && maxU === 0) return value const owner = isWidth ? ownerWidth : ownerHeight let v = value // Inlined resolveValue: Unit.Point=1, Unit.Percent=2. `m === m` is !isNaN. if (maxU === 1) { if (v > maxV.value) v = maxV.value } else if (maxU === 2) { const m = (maxV.value * owner) / 100 if (m === m && v > m) v = m } if (minU === 1) { if (v < minV.value) v = minV.value } else if (minU === 2) { const m = (minV.value * owner) / 100 if (m === m && v < m) v = m } return v } function zeroLayoutRecursive(node: Node): void { for (const c of node.children) { c.layout.left = 0 c.layout.top = 0 c.layout.width = 0 c.layout.height = 0 // Invalidate layout cache — without this, unhide → calculateLayout finds // the child clean (!isDirty_) with _hasL intact, hits the cache at line // ~1086, restores stale _lOutW/_lOutH, and returns early — skipping the // child-positioning recursion. Grandchildren stay at (0,0,0,0) from the // zeroing above and render invisible. isDirty_=true also gates _cN and // _fbBasis via their (sameGen || !isDirty_) checks — _cGen/_fbGen freeze // during hide so sameGen is false on unhide. c.isDirty_ = true c._hasL = false c._hasM = false zeroLayoutRecursive(c) } } function collectLayoutChildren(node: Node, flow: Node[], abs: Node[]): void { // Partition a node's children into flow and absolute lists, flattening // display:contents subtrees so their children are laid out as direct // children of this node (per CSS display:contents spec — the box is removed // from the layout tree but its children remain, lifted to the grandparent). for (const c of node.children) { const disp = c.style.display if (disp === Display.None) { c.layout.left = 0 c.layout.top = 0 c.layout.width = 0 c.layout.height = 0 zeroLayoutRecursive(c) } else if (disp === Display.Contents) { c.layout.left = 0 c.layout.top = 0 c.layout.width = 0 c.layout.height = 0 // Recurse — nested display:contents lifts all the way up. The contents // node's own margin/padding/position/dimensions are ignored. collectLayoutChildren(c, flow, abs) } else if (c.style.positionType === PositionType.Absolute) { abs.push(c) } else { flow.push(c) } } } function roundLayout( node: Node, scale: number, absLeft: number, absTop: number, ): void { if (scale === 0) return const l = node.layout const nodeLeft = l.left const nodeTop = l.top const nodeWidth = l.width const nodeHeight = l.height const absNodeLeft = absLeft + nodeLeft const absNodeTop = absTop + nodeTop // Upstream YGRoundValueToPixelGrid: text nodes (has measureFunc) floor their // positions so wrapped text never starts past its allocated column. Width // uses ceil-if-fractional to avoid clipping the last glyph. Non-text nodes // use standard round. Matches yoga's PixelGrid.cpp — without this, justify // center/space-evenly positions are off-by-one vs WASM and flex-shrink // overflow places siblings at the wrong column. const isText = node.measureFunc !== null l.left = roundValue(nodeLeft, scale, false, isText) l.top = roundValue(nodeTop, scale, false, isText) // Width/height rounded via absolute edges to avoid cumulative drift const absRight = absNodeLeft + nodeWidth const absBottom = absNodeTop + nodeHeight const hasFracW = !isWholeNumber(nodeWidth * scale) const hasFracH = !isWholeNumber(nodeHeight * scale) l.width = roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) - roundValue(absNodeLeft, scale, false, isText) l.height = roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) - roundValue(absNodeTop, scale, false, isText) for (const c of node.children) { roundLayout(c, scale, absNodeLeft, absNodeTop) } } function isWholeNumber(v: number): boolean { const frac = v - Math.floor(v) return frac < 0.0001 || frac > 0.9999 } function roundValue( v: number, scale: number, forceCeil: boolean, forceFloor: boolean, ): number { let scaled = v * scale let frac = scaled - Math.floor(scaled) if (frac < 0) frac += 1 // Float-epsilon tolerance matches upstream YGDoubleEqual (1e-4) if (frac < 0.0001) { scaled = Math.floor(scaled) } else if (frac > 0.9999) { scaled = Math.ceil(scaled) } else if (forceCeil) { scaled = Math.ceil(scaled) } else if (forceFloor) { scaled = Math.floor(scaled) } else { // Round half-up (>= 0.5 goes up), per upstream scaled = Math.floor(scaled) + (frac >= 0.4999 ? 1 : 0) } return scaled / scale } // -- // Helpers function parseDimension(v: number | string | undefined): Value { if (v === undefined) return UNDEFINED_VALUE if (v === 'auto') return AUTO_VALUE if (typeof v === 'number') { // WASM yoga's YGFloatIsUndefined treats NaN and ±Infinity as undefined. // Ink passes height={Infinity} (e.g. LogSelector maxHeight default) and // expects it to mean "unconstrained" — storing it as a literal point value // makes the node height Infinity and breaks all downstream layout. return Number.isFinite(v) ? pointValue(v) : UNDEFINED_VALUE } if (typeof v === 'string' && v.endsWith('%')) { return percentValue(parseFloat(v)) } const n = parseFloat(v) return isNaN(n) ? UNDEFINED_VALUE : pointValue(n) } function physicalEdge(edge: Edge): number { switch (edge) { case Edge.Left: case Edge.Start: return EDGE_LEFT case Edge.Top: return EDGE_TOP case Edge.Right: case Edge.End: return EDGE_RIGHT case Edge.Bottom: return EDGE_BOTTOM default: return EDGE_LEFT } } // -- // Module API matching yoga-layout/load export type Yoga = { Config: { create(): Config destroy(config: Config): void } Node: { create(config?: Config): Node createDefault(): Node createWithConfig(config: Config): Node destroy(node: Node): void } } const YOGA_INSTANCE: Yoga = { Config: { create: createConfig, destroy() {}, }, Node: { create: (config?: Config) => new Node(config), createDefault: () => new Node(), createWithConfig: (config: Config) => new Node(config), destroy() {}, }, } export function loadYoga(): Promise { return Promise.resolve(YOGA_INSTANCE) } export default YOGA_INSTANCE