source dump of claude code
at main 2578 lines 83 kB view raw
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 41import { 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 60export { 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 82export type Value = { 83 unit: Unit 84 value: number 85} 86 87const UNDEFINED_VALUE: Value = { unit: Unit.Undefined, value: NaN } 88const AUTO_VALUE: Value = { unit: Unit.Auto, value: NaN } 89 90function pointValue(v: number): Value { 91 return { unit: Unit.Point, value: v } 92} 93function percentValue(v: number): Value { 94 return { unit: Unit.Percent, value: v } 95} 96 97function 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 108function isDefined(n: number): boolean { 109 return !isNaN(n) 110} 111 112// NaN-safe equality for layout-cache input comparison 113function sameFloat(a: number, b: number): boolean { 114 return a === b || (a !== a && b !== b) 115} 116 117// -- 118// Layout result (computed values) 119 120type 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 134type 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 167function 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 199const EDGE_LEFT = 0 200const EDGE_TOP = 1 201const EDGE_RIGHT = 2 202const EDGE_BOTTOM = 3 203 204function 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 233function 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 250function 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. 256function hasAnyAutoEdge(edges: Value[]): boolean { 257 for (let i = 0; i < 9; i++) if (edges[i]!.unit === 3) return true 258 return false 259} 260function 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. 269function 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 312function isRow(dir: FlexDirection): boolean { 313 return dir === FlexDirection.Row || dir === FlexDirection.RowReverse 314} 315function isReverse(dir: FlexDirection): boolean { 316 return dir === FlexDirection.RowReverse || dir === FlexDirection.ColumnReverse 317} 318function crossAxis(dir: FlexDirection): FlexDirection { 319 return isRow(dir) ? FlexDirection.Column : FlexDirection.Row 320} 321function 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} 333function 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 349export type MeasureFunction = ( 350 width: number, 351 widthMode: MeasureMode, 352 height: number, 353 heightMode: MeasureMode, 354) => { width: number; height: number } 355 356export type Size = { width: number; height: number } 357 358// -- 359// Config 360 361export 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 374function 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 403export 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 966const DEFAULT_CONFIG = createConfig() 967 968const CACHE_SLOTS = 4 969function 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. 1022function 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. 1039let _generation = 0 1040let _yogaNodesVisited = 0 1041let _yogaMeasureCalls = 0 1042let _yogaCacheHits = 0 1043let _yogaLiveNodes = 0 1044export 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 1058function 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 1904function 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 2023function 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 2039function 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 2059function 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 2174function 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 2182function 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 2284function 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 2294function 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. 2304function 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. 2325function 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 2334function 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 2345function 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 2352function 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 2386function 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 2406function 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 2435function 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 2478function isWholeNumber(v: number): boolean { 2479 const frac = v - Math.floor(v) 2480 return frac < 0.0001 || frac > 0.9999 2481} 2482 2483function 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 2511function 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 2528function 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 2548export 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 2561const 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 2574export function loadYoga(): Promise<Yoga> { 2575 return Promise.resolve(YOGA_INSTANCE) 2576} 2577 2578export default YOGA_INSTANCE