📄 File detail
native-ts/yoga-layout/index.ts
🎯 Use case
This file lives under “native-ts/”, which covers Node-side helpers (indexing, diffs, small native-style utilities). On the API surface it exposes Value, MeasureFunction, Size, Config, and Node (and more) — mainly types, interfaces, or factory objects. It composes internal code from enums (relative imports). What the file header says: 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 cove.
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
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 (chil
📤 Exports (heuristic)
ValueMeasureFunctionSizeConfigNodegetYogaCountersYogaloadYogadefaultAlignBoxSizingDimensionDirectionDisplayEdgeErrataExperimentalFeatureFlexDirectionGutterJustifyMeasureModeOverflowPositionTypeUnitWrap
🖥️ Source preview
/**
* 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 <Text> inside <Box flexGrow={1}> 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<Yoga> {
return Promise.resolve(YOGA_INSTANCE)
}
export default YOGA_INSTANCE