/ native-ts / yoga-layout / index.ts
index.ts
   1  /**
   2   * Pure-TypeScript port of yoga-layout (Meta's flexbox engine).
   3   *
   4   * This matches the `yoga-layout/load` API surface used by src/ink/layout/yoga.ts.
   5   * The upstream C++ source is ~2500 lines in CalculateLayout.cpp alone; this port
   6   * is a simplified single-pass flexbox implementation that covers the subset of
   7   * features Ink actually uses:
   8   *   - flex-direction (row/column + reverse)
   9   *   - flex-grow / flex-shrink / flex-basis
  10   *   - align-items / align-self (stretch, flex-start, center, flex-end)
  11   *   - justify-content (all six values)
  12   *   - margin / padding / border / gap
  13   *   - width / height / min / max (point, percent, auto)
  14   *   - position: relative / absolute
  15   *   - display: flex / none
  16   *   - measure functions (for text nodes)
  17   *
  18   * Also implemented for spec parity (not used by Ink):
  19   *   - margin: auto (main + cross axis, overrides justify/align)
  20   *   - multi-pass flex clamping when children hit min/max constraints
  21   *   - flex-grow/shrink against container min/max when size is indefinite
  22   *
  23   * Also implemented for spec parity (not used by Ink):
  24   *   - flex-wrap: wrap / wrap-reverse (multi-line flex)
  25   *   - align-content (positions wrapped lines on cross axis)
  26   *
  27   * Also implemented for spec parity (not used by Ink):
  28   *   - display: contents (children lifted to grandparent, box removed)
  29   *
  30   * Also implemented for spec parity (not used by Ink):
  31   *   - baseline alignment (align-items/align-self: baseline)
  32   *
  33   * Not implemented (not used by Ink):
  34   *   - aspect-ratio
  35   *   - box-sizing: content-box
  36   *   - RTL direction (Ink always passes Direction.LTR)
  37   *
  38   * Upstream: https://github.com/facebook/yoga
  39   */
  40  
  41  import {
  42    Align,
  43    BoxSizing,
  44    Dimension,
  45    Direction,
  46    Display,
  47    Edge,
  48    Errata,
  49    ExperimentalFeature,
  50    FlexDirection,
  51    Gutter,
  52    Justify,
  53    MeasureMode,
  54    Overflow,
  55    PositionType,
  56    Unit,
  57    Wrap,
  58  } from './enums.js'
  59  
  60  export {
  61    Align,
  62    BoxSizing,
  63    Dimension,
  64    Direction,
  65    Display,
  66    Edge,
  67    Errata,
  68    ExperimentalFeature,
  69    FlexDirection,
  70    Gutter,
  71    Justify,
  72    MeasureMode,
  73    Overflow,
  74    PositionType,
  75    Unit,
  76    Wrap,
  77  }
  78  
  79  // --
  80  // Value types
  81  
  82  export type Value = {
  83    unit: Unit
  84    value: number
  85  }
  86  
  87  const UNDEFINED_VALUE: Value = { unit: Unit.Undefined, value: NaN }
  88  const AUTO_VALUE: Value = { unit: Unit.Auto, value: NaN }
  89  
  90  function pointValue(v: number): Value {
  91    return { unit: Unit.Point, value: v }
  92  }
  93  function percentValue(v: number): Value {
  94    return { unit: Unit.Percent, value: v }
  95  }
  96  
  97  function resolveValue(v: Value, ownerSize: number): number {
  98    switch (v.unit) {
  99      case Unit.Point:
 100        return v.value
 101      case Unit.Percent:
 102        return isNaN(ownerSize) ? NaN : (v.value * ownerSize) / 100
 103      default:
 104        return NaN
 105    }
 106  }
 107  
 108  function isDefined(n: number): boolean {
 109    return !isNaN(n)
 110  }
 111  
 112  // NaN-safe equality for layout-cache input comparison
 113  function sameFloat(a: number, b: number): boolean {
 114    return a === b || (a !== a && b !== b)
 115  }
 116  
 117  // --
 118  // Layout result (computed values)
 119  
 120  type Layout = {
 121    left: number
 122    top: number
 123    width: number
 124    height: number
 125    // Computed per-edge values (resolved to physical edges)
 126    border: [number, number, number, number] // left, top, right, bottom
 127    padding: [number, number, number, number]
 128    margin: [number, number, number, number]
 129  }
 130  
 131  // --
 132  // Style (input values)
 133  
 134  type Style = {
 135    direction: Direction
 136    flexDirection: FlexDirection
 137    justifyContent: Justify
 138    alignItems: Align
 139    alignSelf: Align
 140    alignContent: Align
 141    flexWrap: Wrap
 142    overflow: Overflow
 143    display: Display
 144    positionType: PositionType
 145  
 146    flexGrow: number
 147    flexShrink: number
 148    flexBasis: Value
 149  
 150    // 9-edge arrays indexed by Edge enum
 151    margin: Value[]
 152    padding: Value[]
 153    border: Value[]
 154    position: Value[]
 155  
 156    // 3-gutter array indexed by Gutter enum
 157    gap: Value[]
 158  
 159    width: Value
 160    height: Value
 161    minWidth: Value
 162    minHeight: Value
 163    maxWidth: Value
 164    maxHeight: Value
 165  }
 166  
 167  function defaultStyle(): Style {
 168    return {
 169      direction: Direction.Inherit,
 170      flexDirection: FlexDirection.Column,
 171      justifyContent: Justify.FlexStart,
 172      alignItems: Align.Stretch,
 173      alignSelf: Align.Auto,
 174      alignContent: Align.FlexStart,
 175      flexWrap: Wrap.NoWrap,
 176      overflow: Overflow.Visible,
 177      display: Display.Flex,
 178      positionType: PositionType.Relative,
 179      flexGrow: 0,
 180      flexShrink: 0,
 181      flexBasis: AUTO_VALUE,
 182      margin: new Array(9).fill(UNDEFINED_VALUE),
 183      padding: new Array(9).fill(UNDEFINED_VALUE),
 184      border: new Array(9).fill(UNDEFINED_VALUE),
 185      position: new Array(9).fill(UNDEFINED_VALUE),
 186      gap: new Array(3).fill(UNDEFINED_VALUE),
 187      width: AUTO_VALUE,
 188      height: AUTO_VALUE,
 189      minWidth: UNDEFINED_VALUE,
 190      minHeight: UNDEFINED_VALUE,
 191      maxWidth: UNDEFINED_VALUE,
 192      maxHeight: UNDEFINED_VALUE,
 193    }
 194  }
 195  
 196  // --
 197  // Edge resolution — yoga's 9-edge model collapsed to 4 physical edges
 198  
 199  const EDGE_LEFT = 0
 200  const EDGE_TOP = 1
 201  const EDGE_RIGHT = 2
 202  const EDGE_BOTTOM = 3
 203  
 204  function resolveEdge(
 205    edges: Value[],
 206    physicalEdge: number,
 207    ownerSize: number,
 208    // For margin/position we allow auto; for padding/border auto resolves to 0
 209    allowAuto = false,
 210  ): number {
 211    // Precedence: specific edge > horizontal/vertical > all
 212    let v = edges[physicalEdge]!
 213    if (v.unit === Unit.Undefined) {
 214      if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) {
 215        v = edges[Edge.Horizontal]!
 216      } else {
 217        v = edges[Edge.Vertical]!
 218      }
 219    }
 220    if (v.unit === Unit.Undefined) {
 221      v = edges[Edge.All]!
 222    }
 223    // Start/End map to Left/Right for LTR (Ink is always LTR)
 224    if (v.unit === Unit.Undefined) {
 225      if (physicalEdge === EDGE_LEFT) v = edges[Edge.Start]!
 226      if (physicalEdge === EDGE_RIGHT) v = edges[Edge.End]!
 227    }
 228    if (v.unit === Unit.Undefined) return 0
 229    if (v.unit === Unit.Auto) return allowAuto ? NaN : 0
 230    return resolveValue(v, ownerSize)
 231  }
 232  
 233  function resolveEdgeRaw(edges: Value[], physicalEdge: number): Value {
 234    let v = edges[physicalEdge]!
 235    if (v.unit === Unit.Undefined) {
 236      if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) {
 237        v = edges[Edge.Horizontal]!
 238      } else {
 239        v = edges[Edge.Vertical]!
 240      }
 241    }
 242    if (v.unit === Unit.Undefined) v = edges[Edge.All]!
 243    if (v.unit === Unit.Undefined) {
 244      if (physicalEdge === EDGE_LEFT) v = edges[Edge.Start]!
 245      if (physicalEdge === EDGE_RIGHT) v = edges[Edge.End]!
 246    }
 247    return v
 248  }
 249  
 250  function isMarginAuto(edges: Value[], physicalEdge: number): boolean {
 251    return resolveEdgeRaw(edges, physicalEdge).unit === Unit.Auto
 252  }
 253  
 254  // Setter helpers for the _hasAutoMargin / _hasPosition fast-path flags.
 255  // Unit.Undefined = 0, Unit.Auto = 3.
 256  function hasAnyAutoEdge(edges: Value[]): boolean {
 257    for (let i = 0; i < 9; i++) if (edges[i]!.unit === 3) return true
 258    return false
 259  }
 260  function hasAnyDefinedEdge(edges: Value[]): boolean {
 261    for (let i = 0; i < 9; i++) if (edges[i]!.unit !== 0) return true
 262    return false
 263  }
 264  
 265  // Hot path: resolve all 4 physical edges in one pass, writing into `out`.
 266  // Equivalent to calling resolveEdge() 4× with allowAuto=false, but hoists the
 267  // shared fallback lookups (Horizontal/Vertical/All/Start/End) and avoids
 268  // allocating a fresh 4-array on every layoutNode() call.
 269  function resolveEdges4Into(
 270    edges: Value[],
 271    ownerSize: number,
 272    out: [number, number, number, number],
 273  ): void {
 274    // Hoist fallbacks once — the 4 per-edge chains share these reads.
 275    const eH = edges[6]! // Edge.Horizontal
 276    const eV = edges[7]! // Edge.Vertical
 277    const eA = edges[8]! // Edge.All
 278    const eS = edges[4]! // Edge.Start
 279    const eE = edges[5]! // Edge.End
 280    const pctDenom = isNaN(ownerSize) ? NaN : ownerSize / 100
 281  
 282    // Left: edges[0] → Horizontal → All → Start
 283    let v = edges[0]!
 284    if (v.unit === 0) v = eH
 285    if (v.unit === 0) v = eA
 286    if (v.unit === 0) v = eS
 287    out[0] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0
 288  
 289    // Top: edges[1] → Vertical → All
 290    v = edges[1]!
 291    if (v.unit === 0) v = eV
 292    if (v.unit === 0) v = eA
 293    out[1] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0
 294  
 295    // Right: edges[2] → Horizontal → All → End
 296    v = edges[2]!
 297    if (v.unit === 0) v = eH
 298    if (v.unit === 0) v = eA
 299    if (v.unit === 0) v = eE
 300    out[2] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0
 301  
 302    // Bottom: edges[3] → Vertical → All
 303    v = edges[3]!
 304    if (v.unit === 0) v = eV
 305    if (v.unit === 0) v = eA
 306    out[3] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0
 307  }
 308  
 309  // --
 310  // Axis helpers
 311  
 312  function isRow(dir: FlexDirection): boolean {
 313    return dir === FlexDirection.Row || dir === FlexDirection.RowReverse
 314  }
 315  function isReverse(dir: FlexDirection): boolean {
 316    return dir === FlexDirection.RowReverse || dir === FlexDirection.ColumnReverse
 317  }
 318  function crossAxis(dir: FlexDirection): FlexDirection {
 319    return isRow(dir) ? FlexDirection.Column : FlexDirection.Row
 320  }
 321  function leadingEdge(dir: FlexDirection): number {
 322    switch (dir) {
 323      case FlexDirection.Row:
 324        return EDGE_LEFT
 325      case FlexDirection.RowReverse:
 326        return EDGE_RIGHT
 327      case FlexDirection.Column:
 328        return EDGE_TOP
 329      case FlexDirection.ColumnReverse:
 330        return EDGE_BOTTOM
 331    }
 332  }
 333  function trailingEdge(dir: FlexDirection): number {
 334    switch (dir) {
 335      case FlexDirection.Row:
 336        return EDGE_RIGHT
 337      case FlexDirection.RowReverse:
 338        return EDGE_LEFT
 339      case FlexDirection.Column:
 340        return EDGE_BOTTOM
 341      case FlexDirection.ColumnReverse:
 342        return EDGE_TOP
 343    }
 344  }
 345  
 346  // --
 347  // Public types
 348  
 349  export type MeasureFunction = (
 350    width: number,
 351    widthMode: MeasureMode,
 352    height: number,
 353    heightMode: MeasureMode,
 354  ) => { width: number; height: number }
 355  
 356  export type Size = { width: number; height: number }
 357  
 358  // --
 359  // Config
 360  
 361  export type Config = {
 362    pointScaleFactor: number
 363    errata: Errata
 364    useWebDefaults: boolean
 365    free(): void
 366    isExperimentalFeatureEnabled(_: ExperimentalFeature): boolean
 367    setExperimentalFeatureEnabled(_: ExperimentalFeature, __: boolean): void
 368    setPointScaleFactor(factor: number): void
 369    getErrata(): Errata
 370    setErrata(errata: Errata): void
 371    setUseWebDefaults(v: boolean): void
 372  }
 373  
 374  function createConfig(): Config {
 375    const config: Config = {
 376      pointScaleFactor: 1,
 377      errata: Errata.None,
 378      useWebDefaults: false,
 379      free() {},
 380      isExperimentalFeatureEnabled() {
 381        return false
 382      },
 383      setExperimentalFeatureEnabled() {},
 384      setPointScaleFactor(f) {
 385        config.pointScaleFactor = f
 386      },
 387      getErrata() {
 388        return config.errata
 389      },
 390      setErrata(e) {
 391        config.errata = e
 392      },
 393      setUseWebDefaults(v) {
 394        config.useWebDefaults = v
 395      },
 396    }
 397    return config
 398  }
 399  
 400  // --
 401  // Node implementation
 402  
 403  export class Node {
 404    style: Style
 405    layout: Layout
 406    parent: Node | null
 407    children: Node[]
 408    measureFunc: MeasureFunction | null
 409    config: Config
 410    isDirty_: boolean
 411    isReferenceBaseline_: boolean
 412  
 413    // Per-layout scratch (not public API)
 414    _flexBasis = 0
 415    _mainSize = 0
 416    _crossSize = 0
 417    _lineIndex = 0
 418    // Fast-path flags maintained by style setters. Per CPU profile, the
 419    // positioning loop calls isMarginAuto 6× and resolveEdgeRaw(position) 4×
 420    // per child per layout pass — ~11k calls for the 1000-node bench, nearly
 421    // all of which return false/undefined since most nodes have no auto
 422    // margins and no position insets. These flags let us skip straight to
 423    // the common case with a single branch.
 424    _hasAutoMargin = false
 425    _hasPosition = false
 426    // Same pattern for the 3× resolveEdges4Into calls at the top of every
 427    // layoutNode(). In the 1000-node bench ~67% of those calls operate on
 428    // all-undefined edge arrays (most nodes have no border; only cols have
 429    // padding; only leaf cells have margin) — a single-branch skip beats
 430    // ~20 property reads + ~15 compares + 4 writes of zeros.
 431    _hasPadding = false
 432    _hasBorder = false
 433    _hasMargin = false
 434    // -- Dirty-flag layout cache. Mirrors upstream CalculateLayout.cpp's
 435    // layoutNodeInternal: skip a subtree entirely when it's clean and we're
 436    // asking the same question we cached the answer to. Two slots since
 437    // each node typically sees a measure call (performLayout=false, from
 438    // computeFlexBasis) followed by a layout call (performLayout=true) with
 439    // different inputs per parent pass — a single slot thrashes. Re-layout
 440    // bench (dirty one leaf, recompute root) went 2.7x→1.1x with this:
 441    // clean siblings skip straight through, only the dirty chain recomputes.
 442    _lW = NaN
 443    _lH = NaN
 444    _lWM: MeasureMode = 0
 445    _lHM: MeasureMode = 0
 446    _lOW = NaN
 447    _lOH = NaN
 448    _lFW = false
 449    _lFH = false
 450    // _hasL stores INPUTS early (before compute) but layout.width/height are
 451    // mutated by the multi-entry cache and by subsequent compute calls with
 452    // different inputs. Without storing OUTPUTS, a _hasL hit returns whatever
 453    // layout.width/height happened to be left by the last call — the scrollbox
 454    // vpH=33→2624 bug. Store + restore outputs like the multi-entry cache does.
 455    _lOutW = NaN
 456    _lOutH = NaN
 457    _hasL = false
 458    _mW = NaN
 459    _mH = NaN
 460    _mWM: MeasureMode = 0
 461    _mHM: MeasureMode = 0
 462    _mOW = NaN
 463    _mOH = NaN
 464    _mOutW = NaN
 465    _mOutH = NaN
 466    _hasM = false
 467    // Cached computeFlexBasis result. For clean children, basis only depends
 468    // on the container's inner dimensions — if those haven't changed, skip the
 469    // layoutNode(performLayout=false) recursion entirely. This is the hot path
 470    // for scroll: 500-message content container is dirty, its 499 clean
 471    // children each get measured ~20× as the dirty chain's measure/layout
 472    // passes cascade. Basis cache short-circuits at the child boundary.
 473    _fbBasis = NaN
 474    _fbOwnerW = NaN
 475    _fbOwnerH = NaN
 476    _fbAvailMain = NaN
 477    _fbAvailCross = NaN
 478    _fbCrossMode: MeasureMode = 0
 479    // Generation at which _fbBasis was written. Dirty nodes from a PREVIOUS
 480    // generation have stale cache (subtree changed), but within the SAME
 481    // generation the cache is fresh — the dirty chain's measure→layout
 482    // cascade invokes computeFlexBasis ≥2^depth times per calculateLayout on
 483    // fresh-mounted items, and the subtree doesn't change between calls.
 484    // Gating on generation instead of isDirty_ lets fresh mounts (virtual
 485    // scroll) cache-hit after first compute: 105k visits → ~10k.
 486    _fbGen = -1
 487    // Multi-entry layout cache — stores (inputs → computed w,h) so hits with
 488    // different inputs than _hasL can restore the right dimensions. Upstream
 489    // yoga uses 16; 4 covers Ink's dirty-chain depth. Packed as flat arrays
 490    // to avoid per-entry object allocs. Slot i uses indices [i*8, i*8+8) in
 491    // _cIn (aW,aH,wM,hM,oW,oH,fW,fH) and [i*2, i*2+2) in _cOut (w,h).
 492    _cIn: Float64Array | null = null
 493    _cOut: Float64Array | null = null
 494    _cGen = -1
 495    _cN = 0
 496    _cWr = 0
 497  
 498    constructor(config?: Config) {
 499      this.style = defaultStyle()
 500      this.layout = {
 501        left: 0,
 502        top: 0,
 503        width: 0,
 504        height: 0,
 505        border: [0, 0, 0, 0],
 506        padding: [0, 0, 0, 0],
 507        margin: [0, 0, 0, 0],
 508      }
 509      this.parent = null
 510      this.children = []
 511      this.measureFunc = null
 512      this.config = config ?? DEFAULT_CONFIG
 513      this.isDirty_ = true
 514      this.isReferenceBaseline_ = false
 515      _yogaLiveNodes++
 516    }
 517  
 518    // -- Tree
 519  
 520    insertChild(child: Node, index: number): void {
 521      child.parent = this
 522      this.children.splice(index, 0, child)
 523      this.markDirty()
 524    }
 525    removeChild(child: Node): void {
 526      const idx = this.children.indexOf(child)
 527      if (idx >= 0) {
 528        this.children.splice(idx, 1)
 529        child.parent = null
 530        this.markDirty()
 531      }
 532    }
 533    getChild(index: number): Node {
 534      return this.children[index]!
 535    }
 536    getChildCount(): number {
 537      return this.children.length
 538    }
 539    getParent(): Node | null {
 540      return this.parent
 541    }
 542  
 543    // -- Lifecycle
 544  
 545    free(): void {
 546      this.parent = null
 547      this.children = []
 548      this.measureFunc = null
 549      this._cIn = null
 550      this._cOut = null
 551      _yogaLiveNodes--
 552    }
 553    freeRecursive(): void {
 554      for (const c of this.children) c.freeRecursive()
 555      this.free()
 556    }
 557    reset(): void {
 558      this.style = defaultStyle()
 559      this.children = []
 560      this.parent = null
 561      this.measureFunc = null
 562      this.isDirty_ = true
 563      this._hasAutoMargin = false
 564      this._hasPosition = false
 565      this._hasPadding = false
 566      this._hasBorder = false
 567      this._hasMargin = false
 568      this._hasL = false
 569      this._hasM = false
 570      this._cN = 0
 571      this._cWr = 0
 572      this._fbBasis = NaN
 573    }
 574  
 575    // -- Dirty tracking
 576  
 577    markDirty(): void {
 578      this.isDirty_ = true
 579      if (this.parent && !this.parent.isDirty_) this.parent.markDirty()
 580    }
 581    isDirty(): boolean {
 582      return this.isDirty_
 583    }
 584    hasNewLayout(): boolean {
 585      return true
 586    }
 587    markLayoutSeen(): void {}
 588  
 589    // -- Measure function
 590  
 591    setMeasureFunc(fn: MeasureFunction | null): void {
 592      this.measureFunc = fn
 593      this.markDirty()
 594    }
 595    unsetMeasureFunc(): void {
 596      this.measureFunc = null
 597      this.markDirty()
 598    }
 599  
 600    // -- Computed layout getters
 601  
 602    getComputedLeft(): number {
 603      return this.layout.left
 604    }
 605    getComputedTop(): number {
 606      return this.layout.top
 607    }
 608    getComputedWidth(): number {
 609      return this.layout.width
 610    }
 611    getComputedHeight(): number {
 612      return this.layout.height
 613    }
 614    getComputedRight(): number {
 615      const p = this.parent
 616      return p ? p.layout.width - this.layout.left - this.layout.width : 0
 617    }
 618    getComputedBottom(): number {
 619      const p = this.parent
 620      return p ? p.layout.height - this.layout.top - this.layout.height : 0
 621    }
 622    getComputedLayout(): {
 623      left: number
 624      top: number
 625      right: number
 626      bottom: number
 627      width: number
 628      height: number
 629    } {
 630      return {
 631        left: this.layout.left,
 632        top: this.layout.top,
 633        right: this.getComputedRight(),
 634        bottom: this.getComputedBottom(),
 635        width: this.layout.width,
 636        height: this.layout.height,
 637      }
 638    }
 639    getComputedBorder(edge: Edge): number {
 640      return this.layout.border[physicalEdge(edge)]!
 641    }
 642    getComputedPadding(edge: Edge): number {
 643      return this.layout.padding[physicalEdge(edge)]!
 644    }
 645    getComputedMargin(edge: Edge): number {
 646      return this.layout.margin[physicalEdge(edge)]!
 647    }
 648  
 649    // -- Style setters: dimensions
 650  
 651    setWidth(v: number | 'auto' | string | undefined): void {
 652      this.style.width = parseDimension(v)
 653      this.markDirty()
 654    }
 655    setWidthPercent(v: number): void {
 656      this.style.width = percentValue(v)
 657      this.markDirty()
 658    }
 659    setWidthAuto(): void {
 660      this.style.width = AUTO_VALUE
 661      this.markDirty()
 662    }
 663    setHeight(v: number | 'auto' | string | undefined): void {
 664      this.style.height = parseDimension(v)
 665      this.markDirty()
 666    }
 667    setHeightPercent(v: number): void {
 668      this.style.height = percentValue(v)
 669      this.markDirty()
 670    }
 671    setHeightAuto(): void {
 672      this.style.height = AUTO_VALUE
 673      this.markDirty()
 674    }
 675    setMinWidth(v: number | string | undefined): void {
 676      this.style.minWidth = parseDimension(v)
 677      this.markDirty()
 678    }
 679    setMinWidthPercent(v: number): void {
 680      this.style.minWidth = percentValue(v)
 681      this.markDirty()
 682    }
 683    setMinHeight(v: number | string | undefined): void {
 684      this.style.minHeight = parseDimension(v)
 685      this.markDirty()
 686    }
 687    setMinHeightPercent(v: number): void {
 688      this.style.minHeight = percentValue(v)
 689      this.markDirty()
 690    }
 691    setMaxWidth(v: number | string | undefined): void {
 692      this.style.maxWidth = parseDimension(v)
 693      this.markDirty()
 694    }
 695    setMaxWidthPercent(v: number): void {
 696      this.style.maxWidth = percentValue(v)
 697      this.markDirty()
 698    }
 699    setMaxHeight(v: number | string | undefined): void {
 700      this.style.maxHeight = parseDimension(v)
 701      this.markDirty()
 702    }
 703    setMaxHeightPercent(v: number): void {
 704      this.style.maxHeight = percentValue(v)
 705      this.markDirty()
 706    }
 707  
 708    // -- Style setters: flex
 709  
 710    setFlexDirection(dir: FlexDirection): void {
 711      this.style.flexDirection = dir
 712      this.markDirty()
 713    }
 714    setFlexGrow(v: number | undefined): void {
 715      this.style.flexGrow = v ?? 0
 716      this.markDirty()
 717    }
 718    setFlexShrink(v: number | undefined): void {
 719      this.style.flexShrink = v ?? 0
 720      this.markDirty()
 721    }
 722    setFlex(v: number | undefined): void {
 723      if (v === undefined || isNaN(v)) {
 724        this.style.flexGrow = 0
 725        this.style.flexShrink = 0
 726      } else if (v > 0) {
 727        this.style.flexGrow = v
 728        this.style.flexShrink = 1
 729        this.style.flexBasis = pointValue(0)
 730      } else if (v < 0) {
 731        this.style.flexGrow = 0
 732        this.style.flexShrink = -v
 733      } else {
 734        this.style.flexGrow = 0
 735        this.style.flexShrink = 0
 736      }
 737      this.markDirty()
 738    }
 739    setFlexBasis(v: number | 'auto' | string | undefined): void {
 740      this.style.flexBasis = parseDimension(v)
 741      this.markDirty()
 742    }
 743    setFlexBasisPercent(v: number): void {
 744      this.style.flexBasis = percentValue(v)
 745      this.markDirty()
 746    }
 747    setFlexBasisAuto(): void {
 748      this.style.flexBasis = AUTO_VALUE
 749      this.markDirty()
 750    }
 751    setFlexWrap(wrap: Wrap): void {
 752      this.style.flexWrap = wrap
 753      this.markDirty()
 754    }
 755  
 756    // -- Style setters: alignment
 757  
 758    setAlignItems(a: Align): void {
 759      this.style.alignItems = a
 760      this.markDirty()
 761    }
 762    setAlignSelf(a: Align): void {
 763      this.style.alignSelf = a
 764      this.markDirty()
 765    }
 766    setAlignContent(a: Align): void {
 767      this.style.alignContent = a
 768      this.markDirty()
 769    }
 770    setJustifyContent(j: Justify): void {
 771      this.style.justifyContent = j
 772      this.markDirty()
 773    }
 774  
 775    // -- Style setters: display / position / overflow
 776  
 777    setDisplay(d: Display): void {
 778      this.style.display = d
 779      this.markDirty()
 780    }
 781    getDisplay(): Display {
 782      return this.style.display
 783    }
 784    setPositionType(t: PositionType): void {
 785      this.style.positionType = t
 786      this.markDirty()
 787    }
 788    setPosition(edge: Edge, v: number | string | undefined): void {
 789      this.style.position[edge] = parseDimension(v)
 790      this._hasPosition = hasAnyDefinedEdge(this.style.position)
 791      this.markDirty()
 792    }
 793    setPositionPercent(edge: Edge, v: number): void {
 794      this.style.position[edge] = percentValue(v)
 795      this._hasPosition = true
 796      this.markDirty()
 797    }
 798    setPositionAuto(edge: Edge): void {
 799      this.style.position[edge] = AUTO_VALUE
 800      this._hasPosition = true
 801      this.markDirty()
 802    }
 803    setOverflow(o: Overflow): void {
 804      this.style.overflow = o
 805      this.markDirty()
 806    }
 807    setDirection(d: Direction): void {
 808      this.style.direction = d
 809      this.markDirty()
 810    }
 811    setBoxSizing(_: BoxSizing): void {
 812      // Not implemented — Ink doesn't use content-box
 813    }
 814  
 815    // -- Style setters: spacing
 816  
 817    setMargin(edge: Edge, v: number | 'auto' | string | undefined): void {
 818      const val = parseDimension(v)
 819      this.style.margin[edge] = val
 820      if (val.unit === Unit.Auto) this._hasAutoMargin = true
 821      else this._hasAutoMargin = hasAnyAutoEdge(this.style.margin)
 822      this._hasMargin =
 823        this._hasAutoMargin || hasAnyDefinedEdge(this.style.margin)
 824      this.markDirty()
 825    }
 826    setMarginPercent(edge: Edge, v: number): void {
 827      this.style.margin[edge] = percentValue(v)
 828      this._hasAutoMargin = hasAnyAutoEdge(this.style.margin)
 829      this._hasMargin = true
 830      this.markDirty()
 831    }
 832    setMarginAuto(edge: Edge): void {
 833      this.style.margin[edge] = AUTO_VALUE
 834      this._hasAutoMargin = true
 835      this._hasMargin = true
 836      this.markDirty()
 837    }
 838    setPadding(edge: Edge, v: number | string | undefined): void {
 839      this.style.padding[edge] = parseDimension(v)
 840      this._hasPadding = hasAnyDefinedEdge(this.style.padding)
 841      this.markDirty()
 842    }
 843    setPaddingPercent(edge: Edge, v: number): void {
 844      this.style.padding[edge] = percentValue(v)
 845      this._hasPadding = true
 846      this.markDirty()
 847    }
 848    setBorder(edge: Edge, v: number | undefined): void {
 849      this.style.border[edge] = v === undefined ? UNDEFINED_VALUE : pointValue(v)
 850      this._hasBorder = hasAnyDefinedEdge(this.style.border)
 851      this.markDirty()
 852    }
 853    setGap(gutter: Gutter, v: number | string | undefined): void {
 854      this.style.gap[gutter] = parseDimension(v)
 855      this.markDirty()
 856    }
 857    setGapPercent(gutter: Gutter, v: number): void {
 858      this.style.gap[gutter] = percentValue(v)
 859      this.markDirty()
 860    }
 861  
 862    // -- Style getters (partial — only what tests need)
 863  
 864    getFlexDirection(): FlexDirection {
 865      return this.style.flexDirection
 866    }
 867    getJustifyContent(): Justify {
 868      return this.style.justifyContent
 869    }
 870    getAlignItems(): Align {
 871      return this.style.alignItems
 872    }
 873    getAlignSelf(): Align {
 874      return this.style.alignSelf
 875    }
 876    getAlignContent(): Align {
 877      return this.style.alignContent
 878    }
 879    getFlexGrow(): number {
 880      return this.style.flexGrow
 881    }
 882    getFlexShrink(): number {
 883      return this.style.flexShrink
 884    }
 885    getFlexBasis(): Value {
 886      return this.style.flexBasis
 887    }
 888    getFlexWrap(): Wrap {
 889      return this.style.flexWrap
 890    }
 891    getWidth(): Value {
 892      return this.style.width
 893    }
 894    getHeight(): Value {
 895      return this.style.height
 896    }
 897    getOverflow(): Overflow {
 898      return this.style.overflow
 899    }
 900    getPositionType(): PositionType {
 901      return this.style.positionType
 902    }
 903    getDirection(): Direction {
 904      return this.style.direction
 905    }
 906  
 907    // -- Unused API stubs (present for API parity)
 908  
 909    copyStyle(_: Node): void {}
 910    setDirtiedFunc(_: unknown): void {}
 911    unsetDirtiedFunc(): void {}
 912    setIsReferenceBaseline(v: boolean): void {
 913      this.isReferenceBaseline_ = v
 914      this.markDirty()
 915    }
 916    isReferenceBaseline(): boolean {
 917      return this.isReferenceBaseline_
 918    }
 919    setAspectRatio(_: number | undefined): void {}
 920    getAspectRatio(): number {
 921      return NaN
 922    }
 923    setAlwaysFormsContainingBlock(_: boolean): void {}
 924  
 925    // -- Layout entry point
 926  
 927    calculateLayout(
 928      ownerWidth: number | undefined,
 929      ownerHeight: number | undefined,
 930      _direction?: Direction,
 931    ): void {
 932      _yogaNodesVisited = 0
 933      _yogaMeasureCalls = 0
 934      _yogaCacheHits = 0
 935      _generation++
 936      const w = ownerWidth === undefined ? NaN : ownerWidth
 937      const h = ownerHeight === undefined ? NaN : ownerHeight
 938      layoutNode(
 939        this,
 940        w,
 941        h,
 942        isDefined(w) ? MeasureMode.Exactly : MeasureMode.Undefined,
 943        isDefined(h) ? MeasureMode.Exactly : MeasureMode.Undefined,
 944        w,
 945        h,
 946        true,
 947      )
 948      // Root's own position = margin + position insets (yoga applies position
 949      // to the root even without a parent container; this matters for rounding
 950      // since the root's abs top/left seeds the pixel-grid walk).
 951      const mar = this.layout.margin
 952      const posL = resolveValue(
 953        resolveEdgeRaw(this.style.position, EDGE_LEFT),
 954        isDefined(w) ? w : 0,
 955      )
 956      const posT = resolveValue(
 957        resolveEdgeRaw(this.style.position, EDGE_TOP),
 958        isDefined(w) ? w : 0,
 959      )
 960      this.layout.left = mar[EDGE_LEFT] + (isDefined(posL) ? posL : 0)
 961      this.layout.top = mar[EDGE_TOP] + (isDefined(posT) ? posT : 0)
 962      roundLayout(this, this.config.pointScaleFactor, 0, 0)
 963    }
 964  }
 965  
 966  const DEFAULT_CONFIG = createConfig()
 967  
 968  const CACHE_SLOTS = 4
 969  function cacheWrite(
 970    node: Node,
 971    aW: number,
 972    aH: number,
 973    wM: MeasureMode,
 974    hM: MeasureMode,
 975    oW: number,
 976    oH: number,
 977    fW: boolean,
 978    fH: boolean,
 979    wasDirty: boolean,
 980  ): void {
 981    if (!node._cIn) {
 982      node._cIn = new Float64Array(CACHE_SLOTS * 8)
 983      node._cOut = new Float64Array(CACHE_SLOTS * 2)
 984    }
 985    // First write after a dirty clears stale entries from before the dirty.
 986    // _cGen < _generation means entries are from a previous calculateLayout;
 987    // if wasDirty, the subtree changed since then → old dimensions invalid.
 988    // Clean nodes' old entries stay — same subtree → same result for same
 989    // inputs, so cross-generation caching works (the scroll hot path where
 990    // 499 clean messages cache-hit while one dirty leaf recomputes).
 991    if (wasDirty && node._cGen !== _generation) {
 992      node._cN = 0
 993      node._cWr = 0
 994    }
 995    // LRU write index wraps; _cN stays at CACHE_SLOTS so the read scan always
 996    // checks all populated slots (not just those since last wrap).
 997    const i = node._cWr++ % CACHE_SLOTS
 998    if (node._cN < CACHE_SLOTS) node._cN = node._cWr
 999    const o = i * 8
1000    const cIn = node._cIn
1001    cIn[o] = aW
1002    cIn[o + 1] = aH
1003    cIn[o + 2] = wM
1004    cIn[o + 3] = hM
1005    cIn[o + 4] = oW
1006    cIn[o + 5] = oH
1007    cIn[o + 6] = fW ? 1 : 0
1008    cIn[o + 7] = fH ? 1 : 0
1009    node._cOut![i * 2] = node.layout.width
1010    node._cOut![i * 2 + 1] = node.layout.height
1011    node._cGen = _generation
1012  }
1013  
1014  // Store computed layout.width/height into the single-slot cache output fields.
1015  // _hasL/_hasM inputs are committed at the TOP of layoutNode (before compute);
1016  // outputs must be committed HERE (after compute) so a cache hit can restore
1017  // the correct dimensions. Without this, a _hasL hit returns whatever
1018  // layout.width/height was left by the last call — which may be the intrinsic
1019  // content height from a heightMode=Undefined measure pass rather than the
1020  // constrained viewport height from the layout pass. That's the scrollbox
1021  // vpH=33→2624 bug: scrollTop clamps to 0, viewport goes blank.
1022  function commitCacheOutputs(node: Node, performLayout: boolean): void {
1023    if (performLayout) {
1024      node._lOutW = node.layout.width
1025      node._lOutH = node.layout.height
1026    } else {
1027      node._mOutW = node.layout.width
1028      node._mOutH = node.layout.height
1029    }
1030  }
1031  
1032  // --
1033  // Core flexbox algorithm
1034  
1035  // Profiling counters — reset per calculateLayout, read via getYogaCounters.
1036  // Incremented on each calculateLayout(). Nodes stamp _fbGen/_cGen when
1037  // their cache is written; a cache entry with gen === _generation was
1038  // computed THIS pass and is fresh regardless of isDirty_ state.
1039  let _generation = 0
1040  let _yogaNodesVisited = 0
1041  let _yogaMeasureCalls = 0
1042  let _yogaCacheHits = 0
1043  let _yogaLiveNodes = 0
1044  export function getYogaCounters(): {
1045    visited: number
1046    measured: number
1047    cacheHits: number
1048    live: number
1049  } {
1050    return {
1051      visited: _yogaNodesVisited,
1052      measured: _yogaMeasureCalls,
1053      cacheHits: _yogaCacheHits,
1054      live: _yogaLiveNodes,
1055    }
1056  }
1057  
1058  function layoutNode(
1059    node: Node,
1060    availableWidth: number,
1061    availableHeight: number,
1062    widthMode: MeasureMode,
1063    heightMode: MeasureMode,
1064    ownerWidth: number,
1065    ownerHeight: number,
1066    performLayout: boolean,
1067    // When true, ignore style dimension on this axis — the flex container
1068    // has already determined the main size (flex-basis + grow/shrink result).
1069    forceWidth = false,
1070    forceHeight = false,
1071  ): void {
1072    _yogaNodesVisited++
1073    const style = node.style
1074    const layout = node.layout
1075  
1076    // Dirty-flag skip: clean subtree + matching inputs → layout object already
1077    // holds the answer. A cached layout result also satisfies a measure request
1078    // (positions are a superset of dimensions); the reverse does not hold.
1079    // Same-generation entries are fresh regardless of isDirty_ — they were
1080    // computed THIS calculateLayout, the subtree hasn't changed since.
1081    // Previous-generation entries need !isDirty_ (a dirty node's cache from
1082    // before the dirty is stale).
1083    // sameGen bypass only for MEASURE calls — a layout-pass cache hit would
1084    // skip the child-positioning recursion (STEP 5), leaving children at
1085    // stale positions. Measure calls only need w/h which the cache stores.
1086    const sameGen = node._cGen === _generation && !performLayout
1087    if (!node.isDirty_ || sameGen) {
1088      if (
1089        !node.isDirty_ &&
1090        node._hasL &&
1091        node._lWM === widthMode &&
1092        node._lHM === heightMode &&
1093        node._lFW === forceWidth &&
1094        node._lFH === forceHeight &&
1095        sameFloat(node._lW, availableWidth) &&
1096        sameFloat(node._lH, availableHeight) &&
1097        sameFloat(node._lOW, ownerWidth) &&
1098        sameFloat(node._lOH, ownerHeight)
1099      ) {
1100        _yogaCacheHits++
1101        layout.width = node._lOutW
1102        layout.height = node._lOutH
1103        return
1104      }
1105      // Multi-entry cache: scan for matching inputs, restore cached w/h on hit.
1106      // Covers the scroll case where a dirty ancestor's measure→layout cascade
1107      // produces N>1 distinct input combos per clean child — the single _hasL
1108      // slot thrashed, forcing full subtree recursion. With 500-message
1109      // scrollbox and one dirty leaf, this took dirty-leaf relayout from
1110      // 76k layoutNode calls (21.7×nodes) to 4k (1.2×nodes), 6.86ms → 550µs.
1111      // Same-generation check covers fresh-mounted (dirty) nodes during
1112      // virtual scroll — the dirty chain invokes them ≥2^depth times, first
1113      // call writes cache, rest hit: 105k visits → ~10k for 1593-node tree.
1114      if (node._cN > 0 && (sameGen || !node.isDirty_)) {
1115        const cIn = node._cIn!
1116        for (let i = 0; i < node._cN; i++) {
1117          const o = i * 8
1118          if (
1119            cIn[o + 2] === widthMode &&
1120            cIn[o + 3] === heightMode &&
1121            cIn[o + 6] === (forceWidth ? 1 : 0) &&
1122            cIn[o + 7] === (forceHeight ? 1 : 0) &&
1123            sameFloat(cIn[o]!, availableWidth) &&
1124            sameFloat(cIn[o + 1]!, availableHeight) &&
1125            sameFloat(cIn[o + 4]!, ownerWidth) &&
1126            sameFloat(cIn[o + 5]!, ownerHeight)
1127          ) {
1128            layout.width = node._cOut![i * 2]!
1129            layout.height = node._cOut![i * 2 + 1]!
1130            _yogaCacheHits++
1131            return
1132          }
1133        }
1134      }
1135      if (
1136        !node.isDirty_ &&
1137        !performLayout &&
1138        node._hasM &&
1139        node._mWM === widthMode &&
1140        node._mHM === heightMode &&
1141        sameFloat(node._mW, availableWidth) &&
1142        sameFloat(node._mH, availableHeight) &&
1143        sameFloat(node._mOW, ownerWidth) &&
1144        sameFloat(node._mOH, ownerHeight)
1145      ) {
1146        layout.width = node._mOutW
1147        layout.height = node._mOutH
1148        _yogaCacheHits++
1149        return
1150      }
1151    }
1152    // Commit cache inputs up front so every return path leaves a valid entry.
1153    // Only clear isDirty_ on the LAYOUT pass — the measure pass (computeFlexBasis
1154    // → layoutNode(performLayout=false)) runs before the layout pass in the same
1155    // calculateLayout call. Clearing dirty during measure lets the subsequent
1156    // layout pass hit the STALE _hasL cache from the previous calculateLayout
1157    // (before children were inserted), so ScrollBox content height never grows
1158    // and sticky-scroll never follows new content. A dirty node's _hasL entry is
1159    // stale by definition — invalidate it so the layout pass recomputes.
1160    const wasDirty = node.isDirty_
1161    if (performLayout) {
1162      node._lW = availableWidth
1163      node._lH = availableHeight
1164      node._lWM = widthMode
1165      node._lHM = heightMode
1166      node._lOW = ownerWidth
1167      node._lOH = ownerHeight
1168      node._lFW = forceWidth
1169      node._lFH = forceHeight
1170      node._hasL = true
1171      node.isDirty_ = false
1172      // Previous approach cleared _cN here to prevent stale pre-dirty entries
1173      // from hitting (long-continuous blank-screen bug). Now replaced by
1174      // generation stamping: the cache check requires sameGen || !isDirty_, so
1175      // previous-generation entries from a dirty node can't hit. Clearing here
1176      // would wipe fresh same-generation entries from an earlier measure call,
1177      // forcing recompute on the layout call.
1178      if (wasDirty) node._hasM = false
1179    } else {
1180      node._mW = availableWidth
1181      node._mH = availableHeight
1182      node._mWM = widthMode
1183      node._mHM = heightMode
1184      node._mOW = ownerWidth
1185      node._mOH = ownerHeight
1186      node._hasM = true
1187      // Don't clear isDirty_. For DIRTY nodes, invalidate _hasL so the upcoming
1188      // performLayout=true call recomputes with the new child set (otherwise
1189      // sticky-scroll never follows new content — the bug from 4557bc9f9c).
1190      // Clean nodes keep _hasL: their layout from the previous generation is
1191      // still valid, they're only here because an ancestor is dirty and called
1192      // with different inputs than cached.
1193      if (wasDirty) node._hasL = false
1194    }
1195  
1196    // Resolve padding/border/margin against ownerWidth (yoga uses ownerWidth for %)
1197    // Write directly into the pre-allocated layout arrays — avoids 3 allocs per
1198    // layoutNode call and 12 resolveEdge calls (was the #1 hotspot per CPU profile).
1199    // Skip entirely when no edges are set — the 4-write zero is cheaper than
1200    // the ~20 reads + ~15 compares resolveEdges4Into does to produce zeros.
1201    const pad = layout.padding
1202    const bor = layout.border
1203    const mar = layout.margin
1204    if (node._hasPadding) resolveEdges4Into(style.padding, ownerWidth, pad)
1205    else pad[0] = pad[1] = pad[2] = pad[3] = 0
1206    if (node._hasBorder) resolveEdges4Into(style.border, ownerWidth, bor)
1207    else bor[0] = bor[1] = bor[2] = bor[3] = 0
1208    if (node._hasMargin) resolveEdges4Into(style.margin, ownerWidth, mar)
1209    else mar[0] = mar[1] = mar[2] = mar[3] = 0
1210  
1211    const paddingBorderWidth = pad[0] + pad[2] + bor[0] + bor[2]
1212    const paddingBorderHeight = pad[1] + pad[3] + bor[1] + bor[3]
1213  
1214    // Resolve style dimensions
1215    const styleWidth = forceWidth ? NaN : resolveValue(style.width, ownerWidth)
1216    const styleHeight = forceHeight
1217      ? NaN
1218      : resolveValue(style.height, ownerHeight)
1219  
1220    // If style dimension is defined, it overrides the available size
1221    let width = availableWidth
1222    let height = availableHeight
1223    let wMode = widthMode
1224    let hMode = heightMode
1225    if (isDefined(styleWidth)) {
1226      width = styleWidth
1227      wMode = MeasureMode.Exactly
1228    }
1229    if (isDefined(styleHeight)) {
1230      height = styleHeight
1231      hMode = MeasureMode.Exactly
1232    }
1233  
1234    // Apply min/max constraints to the node's own dimensions
1235    width = boundAxis(style, true, width, ownerWidth, ownerHeight)
1236    height = boundAxis(style, false, height, ownerWidth, ownerHeight)
1237  
1238    // Measure-func leaf node
1239    if (node.measureFunc && node.children.length === 0) {
1240      const innerW =
1241        wMode === MeasureMode.Undefined
1242          ? NaN
1243          : Math.max(0, width - paddingBorderWidth)
1244      const innerH =
1245        hMode === MeasureMode.Undefined
1246          ? NaN
1247          : Math.max(0, height - paddingBorderHeight)
1248      _yogaMeasureCalls++
1249      const measured = node.measureFunc(innerW, wMode, innerH, hMode)
1250      node.layout.width =
1251        wMode === MeasureMode.Exactly
1252          ? width
1253          : boundAxis(
1254              style,
1255              true,
1256              (measured.width ?? 0) + paddingBorderWidth,
1257              ownerWidth,
1258              ownerHeight,
1259            )
1260      node.layout.height =
1261        hMode === MeasureMode.Exactly
1262          ? height
1263          : boundAxis(
1264              style,
1265              false,
1266              (measured.height ?? 0) + paddingBorderHeight,
1267              ownerWidth,
1268              ownerHeight,
1269            )
1270      commitCacheOutputs(node, performLayout)
1271      // Write cache even for dirty nodes — fresh-mounted items during virtual
1272      // scroll are dirty on first layout, but the dirty chain's measure→layout
1273      // cascade invokes them ≥2^depth times per calculateLayout. Writing here
1274      // lets the 2nd+ calls hit cache (isDirty_ was cleared in the layout pass
1275      // above). Measured: 105k visits → 10k for a 1593-node fresh-mount tree.
1276      cacheWrite(
1277        node,
1278        availableWidth,
1279        availableHeight,
1280        widthMode,
1281        heightMode,
1282        ownerWidth,
1283        ownerHeight,
1284        forceWidth,
1285        forceHeight,
1286        wasDirty,
1287      )
1288      return
1289    }
1290  
1291    // Leaf node with no children and no measure func
1292    if (node.children.length === 0) {
1293      node.layout.width =
1294        wMode === MeasureMode.Exactly
1295          ? width
1296          : boundAxis(style, true, paddingBorderWidth, ownerWidth, ownerHeight)
1297      node.layout.height =
1298        hMode === MeasureMode.Exactly
1299          ? height
1300          : boundAxis(style, false, paddingBorderHeight, ownerWidth, ownerHeight)
1301      commitCacheOutputs(node, performLayout)
1302      // Write cache even for dirty nodes — fresh-mounted items during virtual
1303      // scroll are dirty on first layout, but the dirty chain's measure→layout
1304      // cascade invokes them ≥2^depth times per calculateLayout. Writing here
1305      // lets the 2nd+ calls hit cache (isDirty_ was cleared in the layout pass
1306      // above). Measured: 105k visits → 10k for a 1593-node fresh-mount tree.
1307      cacheWrite(
1308        node,
1309        availableWidth,
1310        availableHeight,
1311        widthMode,
1312        heightMode,
1313        ownerWidth,
1314        ownerHeight,
1315        forceWidth,
1316        forceHeight,
1317        wasDirty,
1318      )
1319      return
1320    }
1321  
1322    // Container with children — run flexbox algorithm
1323    const mainAxis = style.flexDirection
1324    const crossAx = crossAxis(mainAxis)
1325    const isMainRow = isRow(mainAxis)
1326  
1327    const mainSize = isMainRow ? width : height
1328    const crossSize = isMainRow ? height : width
1329    const mainMode = isMainRow ? wMode : hMode
1330    const crossMode = isMainRow ? hMode : wMode
1331    const mainPadBorder = isMainRow ? paddingBorderWidth : paddingBorderHeight
1332    const crossPadBorder = isMainRow ? paddingBorderHeight : paddingBorderWidth
1333  
1334    const innerMainSize = isDefined(mainSize)
1335      ? Math.max(0, mainSize - mainPadBorder)
1336      : NaN
1337    const innerCrossSize = isDefined(crossSize)
1338      ? Math.max(0, crossSize - crossPadBorder)
1339      : NaN
1340  
1341    // Resolve gap
1342    const gapMain = resolveGap(
1343      style,
1344      isMainRow ? Gutter.Column : Gutter.Row,
1345      innerMainSize,
1346    )
1347  
1348    // Partition children into flow vs absolute. display:contents nodes are
1349    // transparent — their children are lifted into the grandparent's child list
1350    // (recursively), and the contents node itself gets zero layout.
1351    const flowChildren: Node[] = []
1352    const absChildren: Node[] = []
1353    collectLayoutChildren(node, flowChildren, absChildren)
1354  
1355    // ownerW/H are the reference sizes for resolving children's percentage
1356    // values. Per CSS, a % width resolves against the parent's content-box
1357    // width. If this node's width is indefinite, children's % widths are also
1358    // indefinite — do NOT fall through to the grandparent's size.
1359    const ownerW = isDefined(width) ? width : NaN
1360    const ownerH = isDefined(height) ? height : NaN
1361    const isWrap = style.flexWrap !== Wrap.NoWrap
1362    const gapCross = resolveGap(
1363      style,
1364      isMainRow ? Gutter.Row : Gutter.Column,
1365      innerCrossSize,
1366    )
1367  
1368    // STEP 1: Compute flex-basis for each flow child and break into lines.
1369    // Single-line (NoWrap) containers always get one line; multi-line containers
1370    // break when accumulated basis+margin+gap exceeds innerMainSize.
1371    for (const c of flowChildren) {
1372      c._flexBasis = computeFlexBasis(
1373        c,
1374        mainAxis,
1375        innerMainSize,
1376        innerCrossSize,
1377        crossMode,
1378        ownerW,
1379        ownerH,
1380      )
1381    }
1382    const lines: Node[][] = []
1383    if (!isWrap || !isDefined(innerMainSize) || flowChildren.length === 0) {
1384      for (const c of flowChildren) c._lineIndex = 0
1385      lines.push(flowChildren)
1386    } else {
1387      // Line-break decisions use the min/max-clamped basis (flexbox spec §9.3.5:
1388      // "hypothetical main size"), not the raw flex-basis.
1389      let lineStart = 0
1390      let lineLen = 0
1391      for (let i = 0; i < flowChildren.length; i++) {
1392        const c = flowChildren[i]!
1393        const hypo = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH)
1394        const outer = Math.max(0, hypo) + childMarginForAxis(c, mainAxis, ownerW)
1395        const withGap = i > lineStart ? gapMain : 0
1396        if (i > lineStart && lineLen + withGap + outer > innerMainSize) {
1397          lines.push(flowChildren.slice(lineStart, i))
1398          lineStart = i
1399          lineLen = outer
1400        } else {
1401          lineLen += withGap + outer
1402        }
1403        c._lineIndex = lines.length
1404      }
1405      lines.push(flowChildren.slice(lineStart))
1406    }
1407    const lineCount = lines.length
1408    const isBaseline = isBaselineLayout(node, flowChildren)
1409  
1410    // STEP 2+3: For each line, resolve flexible lengths and lay out children to
1411    // measure cross sizes. Track per-line consumed main and max cross.
1412    const lineConsumedMain: number[] = new Array(lineCount)
1413    const lineCrossSizes: number[] = new Array(lineCount)
1414    // Baseline layout tracks max ascent (baseline + leading margin) per line so
1415    // baseline-aligned items can be positioned at maxAscent - childBaseline.
1416    const lineMaxAscent: number[] = isBaseline ? new Array(lineCount).fill(0) : []
1417    let maxLineMain = 0
1418    let totalLinesCross = 0
1419    for (let li = 0; li < lineCount; li++) {
1420      const line = lines[li]!
1421      const lineGap = line.length > 1 ? gapMain * (line.length - 1) : 0
1422      let lineBasis = lineGap
1423      for (const c of line) {
1424        lineBasis += c._flexBasis + childMarginForAxis(c, mainAxis, ownerW)
1425      }
1426      // Resolve flexible lengths against available inner main. For indefinite
1427      // containers with min/max, flex against the clamped size.
1428      let availMain = innerMainSize
1429      if (!isDefined(availMain)) {
1430        const mainOwner = isMainRow ? ownerWidth : ownerHeight
1431        const minM = resolveValue(
1432          isMainRow ? style.minWidth : style.minHeight,
1433          mainOwner,
1434        )
1435        const maxM = resolveValue(
1436          isMainRow ? style.maxWidth : style.maxHeight,
1437          mainOwner,
1438        )
1439        if (isDefined(maxM) && lineBasis > maxM - mainPadBorder) {
1440          availMain = Math.max(0, maxM - mainPadBorder)
1441        } else if (isDefined(minM) && lineBasis < minM - mainPadBorder) {
1442          availMain = Math.max(0, minM - mainPadBorder)
1443        }
1444      }
1445      resolveFlexibleLengths(
1446        line,
1447        availMain,
1448        lineBasis,
1449        isMainRow,
1450        ownerW,
1451        ownerH,
1452      )
1453  
1454      // Lay out each child in this line to measure cross
1455      let lineCross = 0
1456      for (const c of line) {
1457        const cStyle = c.style
1458        const childAlign =
1459          cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf
1460        const cMarginCross = childMarginForAxis(c, crossAx, ownerW)
1461        let childCrossSize = NaN
1462        let childCrossMode: MeasureMode = MeasureMode.Undefined
1463        const resolvedCrossStyle = resolveValue(
1464          isMainRow ? cStyle.height : cStyle.width,
1465          isMainRow ? ownerH : ownerW,
1466        )
1467        const crossLeadE = isMainRow ? EDGE_TOP : EDGE_LEFT
1468        const crossTrailE = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT
1469        const hasCrossAutoMargin =
1470          c._hasAutoMargin &&
1471          (isMarginAuto(cStyle.margin, crossLeadE) ||
1472            isMarginAuto(cStyle.margin, crossTrailE))
1473        // Single-line stretch goes directly to the container cross size.
1474        // Multi-line wrap measures intrinsic cross (Undefined mode) so
1475        // flex-grow grandchildren don't expand to the container — the line
1476        // cross size is determined first, then items are re-stretched.
1477        if (isDefined(resolvedCrossStyle)) {
1478          childCrossSize = resolvedCrossStyle
1479          childCrossMode = MeasureMode.Exactly
1480        } else if (
1481          childAlign === Align.Stretch &&
1482          !hasCrossAutoMargin &&
1483          !isWrap &&
1484          isDefined(innerCrossSize) &&
1485          crossMode === MeasureMode.Exactly
1486        ) {
1487          childCrossSize = Math.max(0, innerCrossSize - cMarginCross)
1488          childCrossMode = MeasureMode.Exactly
1489        } else if (!isWrap && isDefined(innerCrossSize)) {
1490          childCrossSize = Math.max(0, innerCrossSize - cMarginCross)
1491          childCrossMode = MeasureMode.AtMost
1492        }
1493        const cw = isMainRow ? c._mainSize : childCrossSize
1494        const ch = isMainRow ? childCrossSize : c._mainSize
1495        layoutNode(
1496          c,
1497          cw,
1498          ch,
1499          isMainRow ? MeasureMode.Exactly : childCrossMode,
1500          isMainRow ? childCrossMode : MeasureMode.Exactly,
1501          ownerW,
1502          ownerH,
1503          performLayout,
1504          isMainRow,
1505          !isMainRow,
1506        )
1507        c._crossSize = isMainRow ? c.layout.height : c.layout.width
1508        lineCross = Math.max(lineCross, c._crossSize + cMarginCross)
1509      }
1510      // Baseline layout: line cross size must fit maxAscent + maxDescent of
1511      // baseline-aligned children (yoga STEP 8). Only applies to row direction.
1512      if (isBaseline) {
1513        let maxAscent = 0
1514        let maxDescent = 0
1515        for (const c of line) {
1516          if (resolveChildAlign(node, c) !== Align.Baseline) continue
1517          const mTop = resolveEdge(c.style.margin, EDGE_TOP, ownerW)
1518          const mBot = resolveEdge(c.style.margin, EDGE_BOTTOM, ownerW)
1519          const ascent = calculateBaseline(c) + mTop
1520          const descent = c.layout.height + mTop + mBot - ascent
1521          if (ascent > maxAscent) maxAscent = ascent
1522          if (descent > maxDescent) maxDescent = descent
1523        }
1524        lineMaxAscent[li] = maxAscent
1525        if (maxAscent + maxDescent > lineCross) {
1526          lineCross = maxAscent + maxDescent
1527        }
1528      }
1529      // layoutNode(c) at line ~1117 above already resolved c.layout.margin[] via
1530      // resolveEdges4Into with the same ownerW — read directly instead of
1531      // re-resolving through childMarginForAxis → 2× resolveEdge.
1532      const mainLead = leadingEdge(mainAxis)
1533      const mainTrail = trailingEdge(mainAxis)
1534      let consumed = lineGap
1535      for (const c of line) {
1536        const cm = c.layout.margin
1537        consumed += c._mainSize + cm[mainLead]! + cm[mainTrail]!
1538      }
1539      lineConsumedMain[li] = consumed
1540      lineCrossSizes[li] = lineCross
1541      maxLineMain = Math.max(maxLineMain, consumed)
1542      totalLinesCross += lineCross
1543    }
1544    const totalCrossGap = lineCount > 1 ? gapCross * (lineCount - 1) : 0
1545    totalLinesCross += totalCrossGap
1546  
1547    // STEP 4: Determine container dimensions. Per yoga's STEP 9, for both
1548    // AtMost (FitContent) and Undefined (MaxContent) the node sizes to its
1549    // content — AtMost is NOT a hard clamp, items may overflow the available
1550    // space (CSS "fit-content" behavior). Only Scroll overflow clamps to the
1551    // available size. Wrap containers that broke into multiple lines under
1552    // AtMost fill the available main size since they wrapped at that boundary.
1553    const isScroll = style.overflow === Overflow.Scroll
1554    const contentMain = maxLineMain + mainPadBorder
1555    const finalMainSize =
1556      mainMode === MeasureMode.Exactly
1557        ? mainSize
1558        : mainMode === MeasureMode.AtMost && isScroll
1559          ? Math.max(Math.min(mainSize, contentMain), mainPadBorder)
1560          : isWrap && lineCount > 1 && mainMode === MeasureMode.AtMost
1561            ? mainSize
1562            : contentMain
1563    const contentCross = totalLinesCross + crossPadBorder
1564    const finalCrossSize =
1565      crossMode === MeasureMode.Exactly
1566        ? crossSize
1567        : crossMode === MeasureMode.AtMost && isScroll
1568          ? Math.max(Math.min(crossSize, contentCross), crossPadBorder)
1569          : contentCross
1570    node.layout.width = boundAxis(
1571      style,
1572      true,
1573      isMainRow ? finalMainSize : finalCrossSize,
1574      ownerWidth,
1575      ownerHeight,
1576    )
1577    node.layout.height = boundAxis(
1578      style,
1579      false,
1580      isMainRow ? finalCrossSize : finalMainSize,
1581      ownerWidth,
1582      ownerHeight,
1583    )
1584    commitCacheOutputs(node, performLayout)
1585    // Write cache even for dirty nodes — fresh-mounted items during virtual scroll
1586    cacheWrite(
1587      node,
1588      availableWidth,
1589      availableHeight,
1590      widthMode,
1591      heightMode,
1592      ownerWidth,
1593      ownerHeight,
1594      forceWidth,
1595      forceHeight,
1596      wasDirty,
1597    )
1598  
1599    if (!performLayout) return
1600  
1601    // STEP 5: Position lines (align-content) and children (justify-content +
1602    // align-items + auto margins).
1603    const actualInnerMain =
1604      (isMainRow ? node.layout.width : node.layout.height) - mainPadBorder
1605    const actualInnerCross =
1606      (isMainRow ? node.layout.height : node.layout.width) - crossPadBorder
1607    const mainLeadEdgePhys = leadingEdge(mainAxis)
1608    const mainTrailEdgePhys = trailingEdge(mainAxis)
1609    const crossLeadEdgePhys = isMainRow ? EDGE_TOP : EDGE_LEFT
1610    const crossTrailEdgePhys = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT
1611    const reversed = isReverse(mainAxis)
1612    const mainContainerSize = isMainRow ? node.layout.width : node.layout.height
1613    const crossLead = pad[crossLeadEdgePhys]! + bor[crossLeadEdgePhys]!
1614  
1615    // Align-content: distribute free cross space among lines. Single-line
1616    // containers use the full cross size for the one line (align-items handles
1617    // positioning within it).
1618    let lineCrossOffset = crossLead
1619    let betweenLines = gapCross
1620    const freeCross = actualInnerCross - totalLinesCross
1621    if (lineCount === 1 && !isWrap && !isBaseline) {
1622      lineCrossSizes[0] = actualInnerCross
1623    } else {
1624      const remCross = Math.max(0, freeCross)
1625      switch (style.alignContent) {
1626        case Align.FlexStart:
1627          break
1628        case Align.Center:
1629          lineCrossOffset += freeCross / 2
1630          break
1631        case Align.FlexEnd:
1632          lineCrossOffset += freeCross
1633          break
1634        case Align.Stretch:
1635          if (lineCount > 0 && remCross > 0) {
1636            const add = remCross / lineCount
1637            for (let i = 0; i < lineCount; i++) lineCrossSizes[i]! += add
1638          }
1639          break
1640        case Align.SpaceBetween:
1641          if (lineCount > 1) betweenLines += remCross / (lineCount - 1)
1642          break
1643        case Align.SpaceAround:
1644          if (lineCount > 0) {
1645            betweenLines += remCross / lineCount
1646            lineCrossOffset += remCross / lineCount / 2
1647          }
1648          break
1649        case Align.SpaceEvenly:
1650          if (lineCount > 0) {
1651            betweenLines += remCross / (lineCount + 1)
1652            lineCrossOffset += remCross / (lineCount + 1)
1653          }
1654          break
1655        default:
1656          break
1657      }
1658    }
1659  
1660    // For wrap-reverse, lines stack from the trailing cross edge. Walk lines in
1661    // order but flip the cross position within the container.
1662    const wrapReverse = style.flexWrap === Wrap.WrapReverse
1663    const crossContainerSize = isMainRow ? node.layout.height : node.layout.width
1664    let lineCrossPos = lineCrossOffset
1665    for (let li = 0; li < lineCount; li++) {
1666      const line = lines[li]!
1667      const lineCross = lineCrossSizes[li]!
1668      const consumedMain = lineConsumedMain[li]!
1669      const n = line.length
1670  
1671      // Re-stretch children whose cross is auto and align is stretch, now that
1672      // the line cross size is known. Needed for multi-line wrap (line cross
1673      // wasn't known during initial measure) AND single-line when the container
1674      // cross was not Exactly (initial stretch at ~line 1250 was skipped because
1675      // innerCrossSize wasn't defined — the container sized to max child cross).
1676      if (isWrap || crossMode !== MeasureMode.Exactly) {
1677        for (const c of line) {
1678          const cStyle = c.style
1679          const childAlign =
1680            cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf
1681          const crossStyleDef = isDefined(
1682            resolveValue(
1683              isMainRow ? cStyle.height : cStyle.width,
1684              isMainRow ? ownerH : ownerW,
1685            ),
1686          )
1687          const hasCrossAutoMargin =
1688            c._hasAutoMargin &&
1689            (isMarginAuto(cStyle.margin, crossLeadEdgePhys) ||
1690              isMarginAuto(cStyle.margin, crossTrailEdgePhys))
1691          if (
1692            childAlign === Align.Stretch &&
1693            !crossStyleDef &&
1694            !hasCrossAutoMargin
1695          ) {
1696            const cMarginCross = childMarginForAxis(c, crossAx, ownerW)
1697            const target = Math.max(0, lineCross - cMarginCross)
1698            if (c._crossSize !== target) {
1699              const cw = isMainRow ? c._mainSize : target
1700              const ch = isMainRow ? target : c._mainSize
1701              layoutNode(
1702                c,
1703                cw,
1704                ch,
1705                MeasureMode.Exactly,
1706                MeasureMode.Exactly,
1707                ownerW,
1708                ownerH,
1709                performLayout,
1710                isMainRow,
1711                !isMainRow,
1712              )
1713              c._crossSize = target
1714            }
1715          }
1716        }
1717      }
1718  
1719      // Justify-content + auto margins for this line
1720      let mainOffset = pad[mainLeadEdgePhys]! + bor[mainLeadEdgePhys]!
1721      let betweenMain = gapMain
1722      let numAutoMarginsMain = 0
1723      for (const c of line) {
1724        if (!c._hasAutoMargin) continue
1725        if (isMarginAuto(c.style.margin, mainLeadEdgePhys)) numAutoMarginsMain++
1726        if (isMarginAuto(c.style.margin, mainTrailEdgePhys)) numAutoMarginsMain++
1727      }
1728      const freeMain = actualInnerMain - consumedMain
1729      const remainingMain = Math.max(0, freeMain)
1730      const autoMarginMainSize =
1731        numAutoMarginsMain > 0 && remainingMain > 0
1732          ? remainingMain / numAutoMarginsMain
1733          : 0
1734      if (numAutoMarginsMain === 0) {
1735        switch (style.justifyContent) {
1736          case Justify.FlexStart:
1737            break
1738          case Justify.Center:
1739            mainOffset += freeMain / 2
1740            break
1741          case Justify.FlexEnd:
1742            mainOffset += freeMain
1743            break
1744          case Justify.SpaceBetween:
1745            if (n > 1) betweenMain += remainingMain / (n - 1)
1746            break
1747          case Justify.SpaceAround:
1748            if (n > 0) {
1749              betweenMain += remainingMain / n
1750              mainOffset += remainingMain / n / 2
1751            }
1752            break
1753          case Justify.SpaceEvenly:
1754            if (n > 0) {
1755              betweenMain += remainingMain / (n + 1)
1756              mainOffset += remainingMain / (n + 1)
1757            }
1758            break
1759        }
1760      }
1761  
1762      const effectiveLineCrossPos = wrapReverse
1763        ? crossContainerSize - lineCrossPos - lineCross
1764        : lineCrossPos
1765  
1766      let pos = mainOffset
1767      for (const c of line) {
1768        const cMargin = c.style.margin
1769        // c.layout.margin[] was populated by resolveEdges4Into inside the
1770        // layoutNode(c) call above (same ownerW). Read resolved values directly
1771        // instead of re-running the edge fallback chain 4× via resolveEdge.
1772        // Auto margins resolve to 0 in layout.margin, so autoMarginMainSize
1773        // substitution still uses the isMarginAuto check against style.
1774        const cLayoutMargin = c.layout.margin
1775        let autoMainLead = false
1776        let autoMainTrail = false
1777        let autoCrossLead = false
1778        let autoCrossTrail = false
1779        let mMainLead: number
1780        let mMainTrail: number
1781        let mCrossLead: number
1782        let mCrossTrail: number
1783        if (c._hasAutoMargin) {
1784          autoMainLead = isMarginAuto(cMargin, mainLeadEdgePhys)
1785          autoMainTrail = isMarginAuto(cMargin, mainTrailEdgePhys)
1786          autoCrossLead = isMarginAuto(cMargin, crossLeadEdgePhys)
1787          autoCrossTrail = isMarginAuto(cMargin, crossTrailEdgePhys)
1788          mMainLead = autoMainLead
1789            ? autoMarginMainSize
1790            : cLayoutMargin[mainLeadEdgePhys]!
1791          mMainTrail = autoMainTrail
1792            ? autoMarginMainSize
1793            : cLayoutMargin[mainTrailEdgePhys]!
1794          mCrossLead = autoCrossLead ? 0 : cLayoutMargin[crossLeadEdgePhys]!
1795          mCrossTrail = autoCrossTrail ? 0 : cLayoutMargin[crossTrailEdgePhys]!
1796        } else {
1797          // Fast path: no auto margins — read resolved values directly.
1798          mMainLead = cLayoutMargin[mainLeadEdgePhys]!
1799          mMainTrail = cLayoutMargin[mainTrailEdgePhys]!
1800          mCrossLead = cLayoutMargin[crossLeadEdgePhys]!
1801          mCrossTrail = cLayoutMargin[crossTrailEdgePhys]!
1802        }
1803  
1804        const mainPos = reversed
1805          ? mainContainerSize - (pos + mMainLead) - c._mainSize
1806          : pos + mMainLead
1807  
1808        const childAlign =
1809          c.style.alignSelf === Align.Auto ? style.alignItems : c.style.alignSelf
1810        let crossPos = effectiveLineCrossPos + mCrossLead
1811        const crossFree = lineCross - c._crossSize - mCrossLead - mCrossTrail
1812        if (autoCrossLead && autoCrossTrail) {
1813          crossPos += Math.max(0, crossFree) / 2
1814        } else if (autoCrossLead) {
1815          crossPos += Math.max(0, crossFree)
1816        } else if (autoCrossTrail) {
1817          // stays at leading
1818        } else {
1819          switch (childAlign) {
1820            case Align.FlexStart:
1821            case Align.Stretch:
1822              if (wrapReverse) crossPos += crossFree
1823              break
1824            case Align.Center:
1825              crossPos += crossFree / 2
1826              break
1827            case Align.FlexEnd:
1828              if (!wrapReverse) crossPos += crossFree
1829              break
1830            case Align.Baseline:
1831              // Row direction only (isBaselineLayout checked this). Position so
1832              // the child's baseline aligns with the line's max ascent. Per
1833              // yoga: top = currentLead + maxAscent - childBaseline + leadingPosition.
1834              if (isBaseline) {
1835                crossPos =
1836                  effectiveLineCrossPos +
1837                  lineMaxAscent[li]! -
1838                  calculateBaseline(c)
1839              }
1840              break
1841            default:
1842              break
1843          }
1844        }
1845  
1846        // Relative position offsets. Fast path: no position insets set →
1847        // skip 4× resolveEdgeRaw + 4× resolveValue + 4× isDefined.
1848        let relX = 0
1849        let relY = 0
1850        if (c._hasPosition) {
1851          const relLeft = resolveValue(
1852            resolveEdgeRaw(c.style.position, EDGE_LEFT),
1853            ownerW,
1854          )
1855          const relRight = resolveValue(
1856            resolveEdgeRaw(c.style.position, EDGE_RIGHT),
1857            ownerW,
1858          )
1859          const relTop = resolveValue(
1860            resolveEdgeRaw(c.style.position, EDGE_TOP),
1861            ownerW,
1862          )
1863          const relBottom = resolveValue(
1864            resolveEdgeRaw(c.style.position, EDGE_BOTTOM),
1865            ownerW,
1866          )
1867          relX = isDefined(relLeft)
1868            ? relLeft
1869            : isDefined(relRight)
1870              ? -relRight
1871              : 0
1872          relY = isDefined(relTop)
1873            ? relTop
1874            : isDefined(relBottom)
1875              ? -relBottom
1876              : 0
1877        }
1878  
1879        if (isMainRow) {
1880          c.layout.left = mainPos + relX
1881          c.layout.top = crossPos + relY
1882        } else {
1883          c.layout.left = crossPos + relX
1884          c.layout.top = mainPos + relY
1885        }
1886        pos += c._mainSize + mMainLead + mMainTrail + betweenMain
1887      }
1888      lineCrossPos += lineCross + betweenLines
1889    }
1890  
1891    // STEP 6: Absolute-positioned children
1892    for (const c of absChildren) {
1893      layoutAbsoluteChild(
1894        node,
1895        c,
1896        node.layout.width,
1897        node.layout.height,
1898        pad,
1899        bor,
1900      )
1901    }
1902  }
1903  
1904  function layoutAbsoluteChild(
1905    parent: Node,
1906    child: Node,
1907    parentWidth: number,
1908    parentHeight: number,
1909    pad: [number, number, number, number],
1910    bor: [number, number, number, number],
1911  ): void {
1912    const cs = child.style
1913    const posLeft = resolveEdgeRaw(cs.position, EDGE_LEFT)
1914    const posRight = resolveEdgeRaw(cs.position, EDGE_RIGHT)
1915    const posTop = resolveEdgeRaw(cs.position, EDGE_TOP)
1916    const posBottom = resolveEdgeRaw(cs.position, EDGE_BOTTOM)
1917  
1918    const rLeft = resolveValue(posLeft, parentWidth)
1919    const rRight = resolveValue(posRight, parentWidth)
1920    const rTop = resolveValue(posTop, parentHeight)
1921    const rBottom = resolveValue(posBottom, parentHeight)
1922  
1923    // Absolute children's percentage dimensions resolve against the containing
1924    // block's padding-box (parent size minus border), per CSS §10.1.
1925    const paddingBoxW = parentWidth - bor[0] - bor[2]
1926    const paddingBoxH = parentHeight - bor[1] - bor[3]
1927    let cw = resolveValue(cs.width, paddingBoxW)
1928    let ch = resolveValue(cs.height, paddingBoxH)
1929  
1930    // If both left+right defined and width not, derive width
1931    if (!isDefined(cw) && isDefined(rLeft) && isDefined(rRight)) {
1932      cw = paddingBoxW - rLeft - rRight
1933    }
1934    if (!isDefined(ch) && isDefined(rTop) && isDefined(rBottom)) {
1935      ch = paddingBoxH - rTop - rBottom
1936    }
1937  
1938    layoutNode(
1939      child,
1940      cw,
1941      ch,
1942      isDefined(cw) ? MeasureMode.Exactly : MeasureMode.Undefined,
1943      isDefined(ch) ? MeasureMode.Exactly : MeasureMode.Undefined,
1944      paddingBoxW,
1945      paddingBoxH,
1946      true,
1947    )
1948  
1949    // Margin of absolute child (applied in addition to insets)
1950    const mL = resolveEdge(cs.margin, EDGE_LEFT, parentWidth)
1951    const mT = resolveEdge(cs.margin, EDGE_TOP, parentWidth)
1952    const mR = resolveEdge(cs.margin, EDGE_RIGHT, parentWidth)
1953    const mB = resolveEdge(cs.margin, EDGE_BOTTOM, parentWidth)
1954  
1955    const mainAxis = parent.style.flexDirection
1956    const reversed = isReverse(mainAxis)
1957    const mainRow = isRow(mainAxis)
1958    const wrapReverse = parent.style.flexWrap === Wrap.WrapReverse
1959    // alignSelf overrides alignItems for absolute children (same as flow items)
1960    const alignment =
1961      cs.alignSelf === Align.Auto ? parent.style.alignItems : cs.alignSelf
1962  
1963    // Position
1964    let left: number
1965    if (isDefined(rLeft)) {
1966      left = bor[0] + rLeft + mL
1967    } else if (isDefined(rRight)) {
1968      left = parentWidth - bor[2] - rRight - child.layout.width - mR
1969    } else if (mainRow) {
1970      // Main axis — justify-content, flipped for reversed
1971      const lead = pad[0] + bor[0]
1972      const trail = parentWidth - pad[2] - bor[2]
1973      left = reversed
1974        ? trail - child.layout.width - mR
1975        : justifyAbsolute(
1976            parent.style.justifyContent,
1977            lead,
1978            trail,
1979            child.layout.width,
1980          ) + mL
1981    } else {
1982      left =
1983        alignAbsolute(
1984          alignment,
1985          pad[0] + bor[0],
1986          parentWidth - pad[2] - bor[2],
1987          child.layout.width,
1988          wrapReverse,
1989        ) + mL
1990    }
1991  
1992    let top: number
1993    if (isDefined(rTop)) {
1994      top = bor[1] + rTop + mT
1995    } else if (isDefined(rBottom)) {
1996      top = parentHeight - bor[3] - rBottom - child.layout.height - mB
1997    } else if (mainRow) {
1998      top =
1999        alignAbsolute(
2000          alignment,
2001          pad[1] + bor[1],
2002          parentHeight - pad[3] - bor[3],
2003          child.layout.height,
2004          wrapReverse,
2005        ) + mT
2006    } else {
2007      const lead = pad[1] + bor[1]
2008      const trail = parentHeight - pad[3] - bor[3]
2009      top = reversed
2010        ? trail - child.layout.height - mB
2011        : justifyAbsolute(
2012            parent.style.justifyContent,
2013            lead,
2014            trail,
2015            child.layout.height,
2016          ) + mT
2017    }
2018  
2019    child.layout.left = left
2020    child.layout.top = top
2021  }
2022  
2023  function justifyAbsolute(
2024    justify: Justify,
2025    leadEdge: number,
2026    trailEdge: number,
2027    childSize: number,
2028  ): number {
2029    switch (justify) {
2030      case Justify.Center:
2031        return leadEdge + (trailEdge - leadEdge - childSize) / 2
2032      case Justify.FlexEnd:
2033        return trailEdge - childSize
2034      default:
2035        return leadEdge
2036    }
2037  }
2038  
2039  function alignAbsolute(
2040    align: Align,
2041    leadEdge: number,
2042    trailEdge: number,
2043    childSize: number,
2044    wrapReverse: boolean,
2045  ): number {
2046    // Wrap-reverse flips the cross axis: flex-start/stretch go to trailing,
2047    // flex-end goes to leading (yoga's absoluteLayoutChild flips the align value
2048    // when the containing block has wrap-reverse).
2049    switch (align) {
2050      case Align.Center:
2051        return leadEdge + (trailEdge - leadEdge - childSize) / 2
2052      case Align.FlexEnd:
2053        return wrapReverse ? leadEdge : trailEdge - childSize
2054      default:
2055        return wrapReverse ? trailEdge - childSize : leadEdge
2056    }
2057  }
2058  
2059  function computeFlexBasis(
2060    child: Node,
2061    mainAxis: FlexDirection,
2062    availableMain: number,
2063    availableCross: number,
2064    crossMode: MeasureMode,
2065    ownerWidth: number,
2066    ownerHeight: number,
2067  ): number {
2068    // Same-generation cache hit: basis was computed THIS calculateLayout, so
2069    // it's fresh regardless of isDirty_. Covers both clean children (scrolling
2070    // past unchanged messages) AND fresh-mounted dirty children (virtual
2071    // scroll mounts new items — the dirty chain's measure→layout cascade
2072    // invokes this ≥2^depth times, but the child's subtree doesn't change
2073    // between calls within one calculateLayout). For clean children with
2074    // cache from a PREVIOUS generation, also hit if inputs match — isDirty_
2075    // gates since a dirty child's previous-gen cache is stale.
2076    const sameGen = child._fbGen === _generation
2077    if (
2078      (sameGen || !child.isDirty_) &&
2079      child._fbCrossMode === crossMode &&
2080      sameFloat(child._fbOwnerW, ownerWidth) &&
2081      sameFloat(child._fbOwnerH, ownerHeight) &&
2082      sameFloat(child._fbAvailMain, availableMain) &&
2083      sameFloat(child._fbAvailCross, availableCross)
2084    ) {
2085      return child._fbBasis
2086    }
2087    const cs = child.style
2088    const isMainRow = isRow(mainAxis)
2089  
2090    // Explicit flex-basis
2091    const basis = resolveValue(cs.flexBasis, availableMain)
2092    if (isDefined(basis)) {
2093      const b = Math.max(0, basis)
2094      child._fbBasis = b
2095      child._fbOwnerW = ownerWidth
2096      child._fbOwnerH = ownerHeight
2097      child._fbAvailMain = availableMain
2098      child._fbAvailCross = availableCross
2099      child._fbCrossMode = crossMode
2100      child._fbGen = _generation
2101      return b
2102    }
2103  
2104    // Style dimension on main axis
2105    const mainStyleDim = isMainRow ? cs.width : cs.height
2106    const mainOwner = isMainRow ? ownerWidth : ownerHeight
2107    const resolved = resolveValue(mainStyleDim, mainOwner)
2108    if (isDefined(resolved)) {
2109      const b = Math.max(0, resolved)
2110      child._fbBasis = b
2111      child._fbOwnerW = ownerWidth
2112      child._fbOwnerH = ownerHeight
2113      child._fbAvailMain = availableMain
2114      child._fbAvailCross = availableCross
2115      child._fbCrossMode = crossMode
2116      child._fbGen = _generation
2117      return b
2118    }
2119  
2120    // Need to measure the child to get its natural size
2121    const crossStyleDim = isMainRow ? cs.height : cs.width
2122    const crossOwner = isMainRow ? ownerHeight : ownerWidth
2123    let crossConstraint = resolveValue(crossStyleDim, crossOwner)
2124    let crossConstraintMode: MeasureMode = isDefined(crossConstraint)
2125      ? MeasureMode.Exactly
2126      : MeasureMode.Undefined
2127    if (!isDefined(crossConstraint) && isDefined(availableCross)) {
2128      crossConstraint = availableCross
2129      crossConstraintMode =
2130        crossMode === MeasureMode.Exactly && isStretchAlign(child)
2131          ? MeasureMode.Exactly
2132          : MeasureMode.AtMost
2133    }
2134  
2135    // Upstream yoga (YGNodeComputeFlexBasisForChild) passes the available inner
2136    // width with mode AtMost when the subtree will call a measure-func — so text
2137    // nodes don't report unconstrained intrinsic width as flex-basis, which
2138    // would force siblings to shrink and the text to wrap at the wrong width.
2139    // Passing Undefined here made Ink's <Text> inside <Box flexGrow={1}> get
2140    // width = intrinsic instead of available, dropping chars at wrap boundaries.
2141    //
2142    // Two constraints on when this applies:
2143    //   - Width only. Height is never constrained during basis measurement —
2144    //     column containers must measure children at natural height so
2145    //     scrollable content can overflow (constraining height clips ScrollBox).
2146    //   - Subtree has a measure-func. Pure layout subtrees (no measure-func)
2147    //     with flex-grow children would grow into the AtMost constraint,
2148    //     inflating the basis (breaks YGMinMaxDimensionTest flex_grow_in_at_most
2149    //     where a flexGrow:1 child should stay at basis 0, not grow to 100).
2150    let mainConstraint = NaN
2151    let mainConstraintMode: MeasureMode = MeasureMode.Undefined
2152    if (isMainRow && isDefined(availableMain) && hasMeasureFuncInSubtree(child)) {
2153      mainConstraint = availableMain
2154      mainConstraintMode = MeasureMode.AtMost
2155    }
2156  
2157    const mw = isMainRow ? mainConstraint : crossConstraint
2158    const mh = isMainRow ? crossConstraint : mainConstraint
2159    const mwMode = isMainRow ? mainConstraintMode : crossConstraintMode
2160    const mhMode = isMainRow ? crossConstraintMode : mainConstraintMode
2161  
2162    layoutNode(child, mw, mh, mwMode, mhMode, ownerWidth, ownerHeight, false)
2163    const b = isMainRow ? child.layout.width : child.layout.height
2164    child._fbBasis = b
2165    child._fbOwnerW = ownerWidth
2166    child._fbOwnerH = ownerHeight
2167    child._fbAvailMain = availableMain
2168    child._fbAvailCross = availableCross
2169    child._fbCrossMode = crossMode
2170    child._fbGen = _generation
2171    return b
2172  }
2173  
2174  function hasMeasureFuncInSubtree(node: Node): boolean {
2175    if (node.measureFunc) return true
2176    for (const c of node.children) {
2177      if (hasMeasureFuncInSubtree(c)) return true
2178    }
2179    return false
2180  }
2181  
2182  function resolveFlexibleLengths(
2183    children: Node[],
2184    availableInnerMain: number,
2185    totalFlexBasis: number,
2186    isMainRow: boolean,
2187    ownerW: number,
2188    ownerH: number,
2189  ): void {
2190    // Multi-pass flex distribution per CSS flexbox spec §9.7 "Resolving Flexible
2191    // Lengths": distribute free space, detect min/max violations, freeze all
2192    // violators, redistribute among unfrozen children. Repeat until stable.
2193    const n = children.length
2194    const frozen: boolean[] = new Array(n).fill(false)
2195    const initialFree = isDefined(availableInnerMain)
2196      ? availableInnerMain - totalFlexBasis
2197      : 0
2198    // Freeze inflexible items at their clamped basis
2199    for (let i = 0; i < n; i++) {
2200      const c = children[i]!
2201      const clamped = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH)
2202      const inflexible =
2203        !isDefined(availableInnerMain) ||
2204        (initialFree >= 0 ? c.style.flexGrow === 0 : c.style.flexShrink === 0)
2205      if (inflexible) {
2206        c._mainSize = Math.max(0, clamped)
2207        frozen[i] = true
2208      } else {
2209        c._mainSize = c._flexBasis
2210      }
2211    }
2212    // Iteratively distribute until no violations. Free space is recomputed each
2213    // pass: initial free space minus the delta frozen children consumed beyond
2214    // (or below) their basis.
2215    const unclamped: number[] = new Array(n)
2216    for (let iter = 0; iter <= n; iter++) {
2217      let frozenDelta = 0
2218      let totalGrow = 0
2219      let totalShrinkScaled = 0
2220      let unfrozenCount = 0
2221      for (let i = 0; i < n; i++) {
2222        const c = children[i]!
2223        if (frozen[i]) {
2224          frozenDelta += c._mainSize - c._flexBasis
2225        } else {
2226          totalGrow += c.style.flexGrow
2227          totalShrinkScaled += c.style.flexShrink * c._flexBasis
2228          unfrozenCount++
2229        }
2230      }
2231      if (unfrozenCount === 0) break
2232      let remaining = initialFree - frozenDelta
2233      // Spec §9.7 step 4c: if sum of flex factors < 1, only distribute
2234      // initialFree × sum, not the full remaining space (partial flex).
2235      if (remaining > 0 && totalGrow > 0 && totalGrow < 1) {
2236        const scaled = initialFree * totalGrow
2237        if (scaled < remaining) remaining = scaled
2238      } else if (remaining < 0 && totalShrinkScaled > 0) {
2239        let totalShrink = 0
2240        for (let i = 0; i < n; i++) {
2241          if (!frozen[i]) totalShrink += children[i]!.style.flexShrink
2242        }
2243        if (totalShrink < 1) {
2244          const scaled = initialFree * totalShrink
2245          if (scaled > remaining) remaining = scaled
2246        }
2247      }
2248      // Compute targets + violations for all unfrozen children
2249      let totalViolation = 0
2250      for (let i = 0; i < n; i++) {
2251        if (frozen[i]) continue
2252        const c = children[i]!
2253        let t = c._flexBasis
2254        if (remaining > 0 && totalGrow > 0) {
2255          t += (remaining * c.style.flexGrow) / totalGrow
2256        } else if (remaining < 0 && totalShrinkScaled > 0) {
2257          t +=
2258            (remaining * (c.style.flexShrink * c._flexBasis)) / totalShrinkScaled
2259        }
2260        unclamped[i] = t
2261        const clamped = Math.max(
2262          0,
2263          boundAxis(c.style, isMainRow, t, ownerW, ownerH),
2264        )
2265        c._mainSize = clamped
2266        totalViolation += clamped - t
2267      }
2268      // Freeze per spec §9.7 step 5: if totalViolation is zero freeze all; if
2269      // positive freeze min-violators; if negative freeze max-violators.
2270      if (totalViolation === 0) break
2271      let anyFrozen = false
2272      for (let i = 0; i < n; i++) {
2273        if (frozen[i]) continue
2274        const v = children[i]!._mainSize - unclamped[i]!
2275        if ((totalViolation > 0 && v > 0) || (totalViolation < 0 && v < 0)) {
2276          frozen[i] = true
2277          anyFrozen = true
2278        }
2279      }
2280      if (!anyFrozen) break
2281    }
2282  }
2283  
2284  function isStretchAlign(child: Node): boolean {
2285    const p = child.parent
2286    if (!p) return false
2287    const align =
2288      child.style.alignSelf === Align.Auto
2289        ? p.style.alignItems
2290        : child.style.alignSelf
2291    return align === Align.Stretch
2292  }
2293  
2294  function resolveChildAlign(parent: Node, child: Node): Align {
2295    return child.style.alignSelf === Align.Auto
2296      ? parent.style.alignItems
2297      : child.style.alignSelf
2298  }
2299  
2300  // Baseline of a node per CSS Flexbox §8.5 / yoga's YGBaseline. Leaf nodes
2301  // (no children) use their own height. Containers recurse into the first
2302  // baseline-aligned child on the first line (or the first flow child if none
2303  // are baseline-aligned), returning that child's baseline + its top offset.
2304  function calculateBaseline(node: Node): number {
2305    let baselineChild: Node | null = null
2306    for (const c of node.children) {
2307      if (c._lineIndex > 0) break
2308      if (c.style.positionType === PositionType.Absolute) continue
2309      if (c.style.display === Display.None) continue
2310      if (
2311        resolveChildAlign(node, c) === Align.Baseline ||
2312        c.isReferenceBaseline_
2313      ) {
2314        baselineChild = c
2315        break
2316      }
2317      if (baselineChild === null) baselineChild = c
2318    }
2319    if (baselineChild === null) return node.layout.height
2320    return calculateBaseline(baselineChild) + baselineChild.layout.top
2321  }
2322  
2323  // A container uses baseline layout only for row direction, when either
2324  // align-items is baseline or any flow child has align-self: baseline.
2325  function isBaselineLayout(node: Node, flowChildren: Node[]): boolean {
2326    if (!isRow(node.style.flexDirection)) return false
2327    if (node.style.alignItems === Align.Baseline) return true
2328    for (const c of flowChildren) {
2329      if (c.style.alignSelf === Align.Baseline) return true
2330    }
2331    return false
2332  }
2333  
2334  function childMarginForAxis(
2335    child: Node,
2336    axis: FlexDirection,
2337    ownerWidth: number,
2338  ): number {
2339    if (!child._hasMargin) return 0
2340    const lead = resolveEdge(child.style.margin, leadingEdge(axis), ownerWidth)
2341    const trail = resolveEdge(child.style.margin, trailingEdge(axis), ownerWidth)
2342    return lead + trail
2343  }
2344  
2345  function resolveGap(style: Style, gutter: Gutter, ownerSize: number): number {
2346    let v = style.gap[gutter]!
2347    if (v.unit === Unit.Undefined) v = style.gap[Gutter.All]!
2348    const r = resolveValue(v, ownerSize)
2349    return isDefined(r) ? Math.max(0, r) : 0
2350  }
2351  
2352  function boundAxis(
2353    style: Style,
2354    isWidth: boolean,
2355    value: number,
2356    ownerWidth: number,
2357    ownerHeight: number,
2358  ): number {
2359    const minV = isWidth ? style.minWidth : style.minHeight
2360    const maxV = isWidth ? style.maxWidth : style.maxHeight
2361    const minU = minV.unit
2362    const maxU = maxV.unit
2363    // Fast path: no min/max constraints set. Per CPU profile this is the
2364    // overwhelmingly common case (~32k calls/layout on the 1000-node bench,
2365    // nearly all with undefined min/max) — skipping 2× resolveValue + 2× isNaN
2366    // that always no-op. Unit.Undefined = 0.
2367    if (minU === 0 && maxU === 0) return value
2368    const owner = isWidth ? ownerWidth : ownerHeight
2369    let v = value
2370    // Inlined resolveValue: Unit.Point=1, Unit.Percent=2. `m === m` is !isNaN.
2371    if (maxU === 1) {
2372      if (v > maxV.value) v = maxV.value
2373    } else if (maxU === 2) {
2374      const m = (maxV.value * owner) / 100
2375      if (m === m && v > m) v = m
2376    }
2377    if (minU === 1) {
2378      if (v < minV.value) v = minV.value
2379    } else if (minU === 2) {
2380      const m = (minV.value * owner) / 100
2381      if (m === m && v < m) v = m
2382    }
2383    return v
2384  }
2385  
2386  function zeroLayoutRecursive(node: Node): void {
2387    for (const c of node.children) {
2388      c.layout.left = 0
2389      c.layout.top = 0
2390      c.layout.width = 0
2391      c.layout.height = 0
2392      // Invalidate layout cache — without this, unhide → calculateLayout finds
2393      // the child clean (!isDirty_) with _hasL intact, hits the cache at line
2394      // ~1086, restores stale _lOutW/_lOutH, and returns early — skipping the
2395      // child-positioning recursion. Grandchildren stay at (0,0,0,0) from the
2396      // zeroing above and render invisible. isDirty_=true also gates _cN and
2397      // _fbBasis via their (sameGen || !isDirty_) checks — _cGen/_fbGen freeze
2398      // during hide so sameGen is false on unhide.
2399      c.isDirty_ = true
2400      c._hasL = false
2401      c._hasM = false
2402      zeroLayoutRecursive(c)
2403    }
2404  }
2405  
2406  function collectLayoutChildren(node: Node, flow: Node[], abs: Node[]): void {
2407    // Partition a node's children into flow and absolute lists, flattening
2408    // display:contents subtrees so their children are laid out as direct
2409    // children of this node (per CSS display:contents spec — the box is removed
2410    // from the layout tree but its children remain, lifted to the grandparent).
2411    for (const c of node.children) {
2412      const disp = c.style.display
2413      if (disp === Display.None) {
2414        c.layout.left = 0
2415        c.layout.top = 0
2416        c.layout.width = 0
2417        c.layout.height = 0
2418        zeroLayoutRecursive(c)
2419      } else if (disp === Display.Contents) {
2420        c.layout.left = 0
2421        c.layout.top = 0
2422        c.layout.width = 0
2423        c.layout.height = 0
2424        // Recurse — nested display:contents lifts all the way up. The contents
2425        // node's own margin/padding/position/dimensions are ignored.
2426        collectLayoutChildren(c, flow, abs)
2427      } else if (c.style.positionType === PositionType.Absolute) {
2428        abs.push(c)
2429      } else {
2430        flow.push(c)
2431      }
2432    }
2433  }
2434  
2435  function roundLayout(
2436    node: Node,
2437    scale: number,
2438    absLeft: number,
2439    absTop: number,
2440  ): void {
2441    if (scale === 0) return
2442    const l = node.layout
2443    const nodeLeft = l.left
2444    const nodeTop = l.top
2445    const nodeWidth = l.width
2446    const nodeHeight = l.height
2447  
2448    const absNodeLeft = absLeft + nodeLeft
2449    const absNodeTop = absTop + nodeTop
2450  
2451    // Upstream YGRoundValueToPixelGrid: text nodes (has measureFunc) floor their
2452    // positions so wrapped text never starts past its allocated column. Width
2453    // uses ceil-if-fractional to avoid clipping the last glyph. Non-text nodes
2454    // use standard round. Matches yoga's PixelGrid.cpp — without this, justify
2455    // center/space-evenly positions are off-by-one vs WASM and flex-shrink
2456    // overflow places siblings at the wrong column.
2457    const isText = node.measureFunc !== null
2458    l.left = roundValue(nodeLeft, scale, false, isText)
2459    l.top = roundValue(nodeTop, scale, false, isText)
2460  
2461    // Width/height rounded via absolute edges to avoid cumulative drift
2462    const absRight = absNodeLeft + nodeWidth
2463    const absBottom = absNodeTop + nodeHeight
2464    const hasFracW = !isWholeNumber(nodeWidth * scale)
2465    const hasFracH = !isWholeNumber(nodeHeight * scale)
2466    l.width =
2467      roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) -
2468      roundValue(absNodeLeft, scale, false, isText)
2469    l.height =
2470      roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) -
2471      roundValue(absNodeTop, scale, false, isText)
2472  
2473    for (const c of node.children) {
2474      roundLayout(c, scale, absNodeLeft, absNodeTop)
2475    }
2476  }
2477  
2478  function isWholeNumber(v: number): boolean {
2479    const frac = v - Math.floor(v)
2480    return frac < 0.0001 || frac > 0.9999
2481  }
2482  
2483  function roundValue(
2484    v: number,
2485    scale: number,
2486    forceCeil: boolean,
2487    forceFloor: boolean,
2488  ): number {
2489    let scaled = v * scale
2490    let frac = scaled - Math.floor(scaled)
2491    if (frac < 0) frac += 1
2492    // Float-epsilon tolerance matches upstream YGDoubleEqual (1e-4)
2493    if (frac < 0.0001) {
2494      scaled = Math.floor(scaled)
2495    } else if (frac > 0.9999) {
2496      scaled = Math.ceil(scaled)
2497    } else if (forceCeil) {
2498      scaled = Math.ceil(scaled)
2499    } else if (forceFloor) {
2500      scaled = Math.floor(scaled)
2501    } else {
2502      // Round half-up (>= 0.5 goes up), per upstream
2503      scaled = Math.floor(scaled) + (frac >= 0.4999 ? 1 : 0)
2504    }
2505    return scaled / scale
2506  }
2507  
2508  // --
2509  // Helpers
2510  
2511  function parseDimension(v: number | string | undefined): Value {
2512    if (v === undefined) return UNDEFINED_VALUE
2513    if (v === 'auto') return AUTO_VALUE
2514    if (typeof v === 'number') {
2515      // WASM yoga's YGFloatIsUndefined treats NaN and ±Infinity as undefined.
2516      // Ink passes height={Infinity} (e.g. LogSelector maxHeight default) and
2517      // expects it to mean "unconstrained" — storing it as a literal point value
2518      // makes the node height Infinity and breaks all downstream layout.
2519      return Number.isFinite(v) ? pointValue(v) : UNDEFINED_VALUE
2520    }
2521    if (typeof v === 'string' && v.endsWith('%')) {
2522      return percentValue(parseFloat(v))
2523    }
2524    const n = parseFloat(v)
2525    return isNaN(n) ? UNDEFINED_VALUE : pointValue(n)
2526  }
2527  
2528  function physicalEdge(edge: Edge): number {
2529    switch (edge) {
2530      case Edge.Left:
2531      case Edge.Start:
2532        return EDGE_LEFT
2533      case Edge.Top:
2534        return EDGE_TOP
2535      case Edge.Right:
2536      case Edge.End:
2537        return EDGE_RIGHT
2538      case Edge.Bottom:
2539        return EDGE_BOTTOM
2540      default:
2541        return EDGE_LEFT
2542    }
2543  }
2544  
2545  // --
2546  // Module API matching yoga-layout/load
2547  
2548  export type Yoga = {
2549    Config: {
2550      create(): Config
2551      destroy(config: Config): void
2552    }
2553    Node: {
2554      create(config?: Config): Node
2555      createDefault(): Node
2556      createWithConfig(config: Config): Node
2557      destroy(node: Node): void
2558    }
2559  }
2560  
2561  const YOGA_INSTANCE: Yoga = {
2562    Config: {
2563      create: createConfig,
2564      destroy() {},
2565    },
2566    Node: {
2567      create: (config?: Config) => new Node(config),
2568      createDefault: () => new Node(),
2569      createWithConfig: (config: Config) => new Node(config),
2570      destroy() {},
2571    },
2572  }
2573  
2574  export function loadYoga(): Promise<Yoga> {
2575    return Promise.resolve(YOGA_INSTANCE)
2576  }
2577  
2578  export default YOGA_INSTANCE