index.ts
native-ts/yoga-layout/index.ts
No strong subsystem tag
2579
Lines
83377
Bytes
24
Exports
1
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 2579 lines, 1 detected imports, and 24 detected exports.
Important relationships
Detected exports
ValueMeasureFunctionSizeConfigNodegetYogaCountersYogaloadYogaAlignBoxSizingDimensionDirectionDisplayEdgeErrataExperimentalFeatureFlexDirectionGutterJustifyMeasureModeOverflowPositionTypeUnitWrap
Keywords
nodestylelayoutchildwidthheightvoidedgeunitchildren
Detected imports
./enums.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/**
* 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