flexily 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +14 -16
  2. package/dist/classic/layout.d.ts.map +1 -1
  3. package/dist/classic/layout.js +10 -1
  4. package/dist/classic/layout.js.map +1 -1
  5. package/dist/classic/node.js +1 -1
  6. package/dist/constants.d.ts +1 -0
  7. package/dist/constants.d.ts.map +1 -1
  8. package/dist/constants.js +2 -1
  9. package/dist/constants.js.map +1 -1
  10. package/dist/index-classic.d.ts +1 -1
  11. package/dist/index-classic.d.ts.map +1 -1
  12. package/dist/index-classic.js +5 -5
  13. package/dist/index-classic.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/layout-helpers.d.ts +1 -4
  19. package/dist/layout-helpers.d.ts.map +1 -1
  20. package/dist/layout-helpers.js +2 -7
  21. package/dist/layout-helpers.js.map +1 -1
  22. package/dist/layout-zero.js +195 -39
  23. package/dist/layout-zero.js.map +1 -1
  24. package/dist/logger.js +2 -2
  25. package/dist/logger.js.map +1 -1
  26. package/dist/node-zero.js +1 -1
  27. package/dist/testing.js +4 -4
  28. package/dist/trace.js +1 -1
  29. package/dist/types.js +2 -2
  30. package/dist/types.js.map +1 -1
  31. package/dist/utils.d.ts +11 -3
  32. package/dist/utils.d.ts.map +1 -1
  33. package/dist/utils.js +46 -21
  34. package/dist/utils.js.map +1 -1
  35. package/package.json +11 -3
  36. package/src/CLAUDE.md +36 -21
  37. package/src/classic/layout.ts +105 -45
  38. package/src/classic/node.ts +60 -0
  39. package/src/constants.ts +2 -1
  40. package/src/index-classic.ts +1 -1
  41. package/src/index.ts +1 -1
  42. package/src/layout-flex-lines.ts +70 -3
  43. package/src/layout-helpers.ts +27 -7
  44. package/src/layout-stats.ts +0 -2
  45. package/src/layout-zero.ts +587 -160
  46. package/src/node-zero.ts +98 -2
  47. package/src/testing.ts +20 -14
  48. package/src/types.ts +22 -15
  49. package/src/utils.ts +47 -21
package/src/CLAUDE.md CHANGED
@@ -16,16 +16,16 @@ Flexily is a pure-JavaScript flexbox layout engine with a Yoga-compatible API. T
16
16
  │ │
17
17
  ┌──────┴──────┐ ┌──────┴───────┐
18
18
  │ node-zero.ts│ │layout-zero.ts │
19
- │ (1412 LOC) │ │ (1781 LOC) │
19
+ │ (1412 LOC) │ │ (2029 LOC) │
20
20
  │ Node class │ │ layoutNode │
21
21
  └──────┬──────┘ └──────┬────────┘
22
22
  │ │
23
23
  ┌──────┴──────┐ ┌──────────────────┼──────────────────┐
24
24
  │ types.ts │ │ │ │
25
25
  │ Interfaces │ layout-helpers.ts layout-flex-lines.ts │
26
- └─────────────┘ (140 LOC) (346 LOC) │
26
+ └─────────────┘ (160 LOC) (349 LOC) │
27
27
  Edge resolution Pre-alloc arrays layout-measure.ts
28
- Line breaking (257 LOC)
28
+ Line breaking (259 LOC)
29
29
  Flex distribution measureNode
30
30
 
31
31
  layout-traversal.ts (70 LOC) - Tree traversal (markSubtreeLayoutSeen, countNodes)
@@ -42,22 +42,22 @@ Flexily is a pure-JavaScript flexbox layout engine with a Yoga-compatible API. T
42
42
  - Factory function API: `Node.create()` (no `new` in user code, though `Node` is a class internally)
43
43
  - Yoga-compatible API surface: same method names, same constants, drop-in replacement
44
44
  - Pure JavaScript: no WASM, no native dependencies, synchronous initialization
45
- - Single-threaded: layout uses module-level pre-allocated arrays (not reentrant)
45
+ - Module-level pre-allocated arrays with save/restore for re-entrancy (measureFunc/baselineFunc may call calculateLayout on other trees)
46
46
 
47
47
  ## Source Files
48
48
 
49
49
  | File | LOC | Role | Hot path? |
50
50
  | ---------------------- | ----- | -------------------------------------------------------------------- | ------------------------------ |
51
- | `layout-zero.ts` | 1781 | Core layout: `computeLayout()`, `layoutNode()` (11 phases) | **Yes** - most critical |
52
- | `layout-helpers.ts` | 140 | Edge resolution: margins, padding, borders | **Yes** - called per edge |
53
- | `layout-flex-lines.ts` | 346 | Pre-alloc arrays, `breakIntoLines()`, `distributeFlexSpaceForLine()` | **Yes** - flex distribution |
54
- | `layout-measure.ts` | 257 | `measureNode()` — intrinsic sizing | **Yes** - sizing pass |
51
+ | `layout-zero.ts` | 2029 | Core layout: `computeLayout()`, `layoutNode()` (11 phases) | **Yes** - most critical |
52
+ | `layout-helpers.ts` | 160 | Edge resolution: margins, padding, borders | **Yes** - called per edge |
53
+ | `layout-flex-lines.ts` | 349 | Pre-alloc arrays, `breakIntoLines()`, `distributeFlexSpaceForLine()` | **Yes** - flex distribution |
54
+ | `layout-measure.ts` | 259 | `measureNode()` — intrinsic sizing | **Yes** - sizing pass |
55
55
  | `layout-traversal.ts` | 70 | Tree traversal: `markSubtreeLayoutSeen()`, `countNodes()` | Moderate |
56
- | `layout-stats.ts` | 43 | Debug/benchmark counters | No (counters only) |
56
+ | `layout-stats.ts` | 41 | Debug/benchmark counters | No (counters only) |
57
57
  | `node-zero.ts` | 1412 | Node class, tree ops, caching | **Yes** - second most critical |
58
- | `types.ts` | 229 | `FlexInfo`, `Style`, `Layout`, `Value` interfaces | No (types only) |
59
- | `utils.ts` | 217 | `resolveValue`, `applyMinMax`, edge helpers, shared traversal stack | Yes (called frequently) |
60
- | `constants.ts` | 81 | Yoga-compatible numeric constants | No |
58
+ | `types.ts` | 232 | `FlexInfo`, `Style`, `Layout`, `Value` interfaces | No (types only) |
59
+ | `utils.ts` | 240 | `resolveValue`, `applyMinMax`, edge helpers, shared traversal stack | Yes (called frequently) |
60
+ | `constants.ts` | 82 | Yoga-compatible numeric constants | No |
61
61
  | `logger.ts` | 67 | Conditional debug logger (`log.debug?.()`) | No (conditional) |
62
62
  | `testing.ts` | 209 | `getLayout`, `diffLayouts`, `expectRelayoutMatchesFresh` | No (test only) |
63
63
  | `classic/` | ~2900 | Allocating reference algorithm | No (debugging only) |
@@ -125,6 +125,7 @@ Single pass over children:
125
125
  ### Phase 7a: Estimate Line Cross Sizes
126
126
 
127
127
  - Tentative cross-axis sizes from definite child dimensions
128
+ - For measureFunc children: uses `child.flex.mainSize` (from flex distribution), not parent `mainAxisSize`. This ensures text wrapping is measured at the child's actual constrained width.
128
129
  - Auto-sized children use 0 (actual size computed in Phase 8)
129
130
 
130
131
  ### Phase 7b: Apply alignContent
@@ -146,6 +147,14 @@ The most complex phase. For each relative child:
146
147
  8. Apply cross-axis alignment offset (flex-end, center, baseline)
147
148
  9. Advance `mainPos` for next child
148
149
 
150
+ ### Phase 9b: Re-stretch Auto-Sized Cross Axis
151
+
152
+ - When cross axis was auto (NaN) during Phase 8, `ALIGN_STRETCH` children need re-stretching to the now-known cross size
153
+
154
+ ### Phase 9c: Re-align Auto-Sized Cross Axis
155
+
156
+ - When `crossAxisSize` was NaN during Phase 8, alignment offsets for `ALIGN_CENTER`, `ALIGN_FLEX_END`, and cross-axis auto margins computed NaN. Phase 9c recomputes these using the final shrink-wrapped cross size.
157
+
149
158
  ### Phase 9: Shrink-Wrap Auto-Sized Containers
150
159
 
151
160
  - For containers without explicit size, compute actual used space from children
@@ -184,7 +193,7 @@ let _lineItemSpacings = new Float64Array(32) // Per-line item spacing
184
193
 
185
194
  These grow dynamically if >32 lines (rare). Total memory: ~1,344 bytes (4 Float64Arrays × 256 bytes + 1 Uint16Array × 64 bytes + Array overhead).
186
195
 
187
- **Consequence: Not reentrant.** Layout is single-threaded; concurrent `calculateLayout()` calls corrupt shared state. This is safe because layout is synchronous.
196
+ **Re-entrancy**: A `measureFunc` or `baselineFunc` may synchronously call `calculateLayout()` on a separate tree. `enterLayout()`/`exitLayout()` in `layout-flex-lines.ts` bracket nested calls to save and restore the module-level scratch arrays, preventing corruption of the outer pass's state. Only allocates on re-entrant calls (depth > 0).
188
197
 
189
198
  ### Per-Node FlexInfo (`node.flex`)
190
199
 
@@ -218,6 +227,8 @@ interface FlexInfo {
218
227
  lastAvailH
219
228
  lastOffsetX
220
229
  lastOffsetY
230
+ lastAbsX
231
+ lastAbsY
221
232
  lastDir
222
233
  layoutValid
223
234
  }
@@ -275,12 +286,16 @@ flex.lastAvailW = availableWidth
275
286
  flex.lastAvailH = availableHeight
276
287
  flex.lastOffsetX = offsetX
277
288
  flex.lastOffsetY = offsetY
289
+ flex.lastAbsX = absX
290
+ flex.lastAbsY = absY
278
291
  flex.lastDir = direction
279
292
  flex.layoutValid = true
280
293
  ```
281
294
 
282
295
  On next call, if `layoutValid && !isDirty && same constraints`, the entire subtree is skipped. Only position delta is propagated (if offset changed).
283
296
 
297
+ **`absX`/`absY` must be fingerprinted** because edge-based rounding depends on absolute position: `width = round(absX + nodeWidth) - round(absX)`. A fractional shift in absX (e.g., from a sibling's width change) changes the rounded result even when availW/availH/direction are unchanged.
298
+
284
299
  **`Object.is()` is required** for NaN-safe comparison. `NaN === NaN` is `false`; `Object.is(NaN, NaN)` is `true`. NaN represents "unconstrained" -- a legitimate and common constraint value.
285
300
 
286
301
  ### Invalidation Triggers
@@ -316,11 +331,11 @@ This is Yoga's algorithm. Layout positions stored in `layout.left`/`layout.top`
316
331
 
317
332
  ## measureNode vs layoutNode
318
333
 
319
- `measureNode()` (~240 lines) is a lightweight alternative to `layoutNode()` (~1650 lines). It computes `width` and `height` but NOT `left`/`top`. Used during Phase 5 for intrinsic sizing of auto-sized container children. Save/restore of `layout.width`/`layout.height` is required around `measureNode` calls because it overwrites those fields.
334
+ `measureNode()` (~260 lines) is a lightweight alternative to `layoutNode()` (~1900 lines). It computes `width` and `height` but NOT `left`/`top`. Used during Phase 5 for intrinsic sizing of auto-sized container children. Save/restore of `layout.width`/`layout.height` is required around `measureNode` calls because it overwrites those fields.
320
335
 
321
- ## Integration: How silvery Uses Flexily
336
+ ## Integration: How Silvery Uses Flexily
322
337
 
323
- silvery uses flexily through an adapter layer:
338
+ Silvery uses Flexily through an adapter layer:
324
339
 
325
340
  1. `silvery/src/layout-engine.ts` defines the `LayoutEngine` / `LayoutNode` interfaces
326
341
  2. `silvery/src/adapters/flexily-zero-adapter.ts` wraps `Node` in `FlexilyZeroNodeAdapter`
@@ -335,7 +350,7 @@ silvery calls `calculateLayout()` on every render. The no-change case (cursor mo
335
350
  | ----------------------------------------- | ---------------------------------------- | ------------------------------------- | ---------------------------------------------- |
336
351
  | `overflow:hidden/scroll` + `flexShrink:0` | Item expands to content (ignores parent) | Item shrinks to fit parent | 4.5: auto min-size = 0 for overflow containers |
337
352
  | Default `flexShrink` | 0 (Yoga native default) | 0 (matches Yoga) | CSS default is 1 |
338
- | Default `flexDirection` | Column | Column | CSS default is Row |
353
+ | Default `flexDirection` | Column | Row (CSS default) | Row |
339
354
  | Baseline alignment | Full spec (recursive first-child) | Simplified (no recursive propagation) | Recursive first-child |
340
355
 
341
356
  The `flexShrink` override for overflow containers (line ~1244 in layout-zero.ts) is the most significant divergence. Without it, `overflow:hidden` children inside constrained parents balloon to content size, defeating the purpose of clipping.
@@ -362,10 +377,10 @@ Edge-based properties use 6-element arrays: `[left, top, right, bottom, start, e
362
377
 
363
378
  Border widths are plain numbers (always points). Logical border slots use `NaN` as "not set" sentinel.
364
379
 
365
- ### Default Style (Yoga-compatible, not CSS)
380
+ ### Default Style
366
381
 
367
382
  ```typescript
368
- flexDirection: COLUMN // CSS default is ROW
383
+ flexDirection: ROW // CSS default (diverges from Yoga's COLUMN)
369
384
  flexShrink: 0 // CSS default is 1
370
385
  alignItems: STRETCH // Same as CSS
371
386
  flexBasis: AUTO // Same as CSS
@@ -451,11 +466,11 @@ cd vendor/flexily && bun run build
451
466
 
452
467
  | Layer | Tests | Command | What it verifies |
453
468
  | ------------------ | --------- | ---------------------------------------------------------------------------- | --------------------------------- |
454
- | Yoga compat | 38 | `bun test tests/yoga-comparison.test.ts tests/yoga-overflow-compare.test.ts` | Identical output to Yoga |
469
+ | Yoga compat | 44 | `bun test tests/yoga-comparison.test.ts tests/yoga-overflow-compare.test.ts` | Identical output to Yoga |
455
470
  | Feature tests | ~110 | `bun test tests/layout.test.ts` | Each flexbox feature in isolation |
456
471
  | **Re-layout fuzz** | **1200+** | `bun test tests/relayout-consistency.test.ts` | Incremental matches fresh |
457
472
  | Mutation testing | 4+ | `bun scripts/mutation-test.ts` | Fuzz catches cache mutations |
458
- | All tests | 1368 | `bun test` | Everything |
473
+ | All tests | 1495 | `bun test` | Everything |
459
474
 
460
475
  The fuzz tests are the most important layer. They've caught 3 distinct caching bugs that all single-pass tests missed.
461
476
 
@@ -205,7 +205,10 @@ function breakIntoLines(children: ChildLayout[], mainAxisSize: number, mainGap:
205
205
  let lineMainSize = 0
206
206
 
207
207
  for (const child of children) {
208
- const childMainSize = child.baseSize + child.mainMargin
208
+ // CSS spec 9.3.4: line breaking uses the "hypothetical main size" which is
209
+ // the flex base size clamped to min/max, not the unclamped base size.
210
+ const hypotheticalMainSize = Math.max(child.minMain, Math.min(child.maxMain, child.baseSize))
211
+ const childMainSize = hypotheticalMainSize + child.mainMargin
209
212
  const gapIfNotFirst = currentLine.length > 0 ? mainGap : 0
210
213
 
211
214
  // Check if child fits on current line
@@ -528,7 +531,9 @@ function layoutNode(
528
531
  // This ensures children's absolute positions include parent's position offset
529
532
  let parentPosOffsetX = 0
530
533
  let parentPosOffsetY = 0
531
- if (style.positionType === C.POSITION_TYPE_STATIC || style.positionType === C.POSITION_TYPE_RELATIVE) {
534
+ // CSS spec: position:static ignores insets (top/left/right/bottom).
535
+ // Only position:relative applies insets as offsets from normal flow position.
536
+ if (style.positionType === C.POSITION_TYPE_RELATIVE) {
532
537
  const leftPos = style.position[0]
533
538
  const topPos = style.position[1]
534
539
  const rightPos = style.position[2]
@@ -935,6 +940,11 @@ function layoutNode(
935
940
  childCross = crossDim.value
936
941
  } else if (crossDim.unit === C.UNIT_PERCENT && !Number.isNaN(crossAxisSize)) {
937
942
  childCross = crossAxisSize * (crossDim.value / 100)
943
+ } else if (childLayout.node.children.length > 0) {
944
+ // Auto-sized container children: Phase 5 already laid them out with
945
+ // layoutNode to compute baseSize. The cross-axis size is available
946
+ // on the child's layout (height for row, width for column).
947
+ childCross = isRow ? childLayout.node.layout.height : childLayout.node.layout.width
938
948
  } else {
939
949
  // Auto - use a default or measure. For now, use 0 and let stretch handle it.
940
950
  childCross = 0
@@ -990,6 +1000,16 @@ function layoutNode(
990
1000
  }
991
1001
  break
992
1002
 
1003
+ case C.ALIGN_SPACE_EVENLY:
1004
+ // Equal spacing between lines and at edges
1005
+ if (numLines > 0) {
1006
+ const spaceEvenlyGap = freeSpace / (numLines + 1)
1007
+ for (let i = 0; i < numLines; i++) {
1008
+ lineCrossOffsets[i]! += spaceEvenlyGap * (i + 1)
1009
+ }
1010
+ }
1011
+ break
1012
+
993
1013
  case C.ALIGN_STRETCH:
994
1014
  // Distribute extra space evenly among lines
995
1015
  if (freeSpace > 0 && numLines > 0) {
@@ -1251,11 +1271,12 @@ function layoutNode(
1251
1271
  const fractionalLeft = innerLeft + childX
1252
1272
  const fractionalTop = innerTop + childY
1253
1273
 
1254
- // Compute position offsets for RELATIVE/STATIC positioned children
1274
+ // Compute position offsets for RELATIVE positioned children
1275
+ // CSS spec: position:static ignores insets; only position:relative applies them.
1255
1276
  // These must be included in the absolute position BEFORE rounding (Yoga-compatible)
1256
1277
  let posOffsetX = 0
1257
1278
  let posOffsetY = 0
1258
- if (childStyle.positionType === C.POSITION_TYPE_RELATIVE || childStyle.positionType === C.POSITION_TYPE_STATIC) {
1279
+ if (childStyle.positionType === C.POSITION_TYPE_RELATIVE) {
1259
1280
  const relLeftPos = childStyle.position[0]
1260
1281
  const relTopPos = childStyle.position[1]
1261
1282
  const relRightPos = childStyle.position[2]
@@ -1469,7 +1490,7 @@ function layoutNode(
1469
1490
  }
1470
1491
  }
1471
1492
 
1472
- if (crossOffset > 0) {
1493
+ if (crossOffset !== 0) {
1473
1494
  if (isRow) {
1474
1495
  child.layout.top += Math.round(crossOffset)
1475
1496
  } else {
@@ -1605,6 +1626,7 @@ function layoutNode(
1605
1626
  // Content box dimensions for percentage resolution of absolute children
1606
1627
  const absContentBoxW = absPaddingBoxW - paddingLeft - paddingRight
1607
1628
  const absContentBoxH = absPaddingBoxH - paddingTop - paddingBottom
1629
+ const isRow = isRowDirection(style.flexDirection)
1608
1630
 
1609
1631
  for (const child of absoluteChildren) {
1610
1632
  const childStyle = child.style
@@ -1626,10 +1648,11 @@ function layoutNode(
1626
1648
  const hasTop = topPos.unit !== C.UNIT_UNDEFINED
1627
1649
  const hasBottom = bottomPos.unit !== C.UNIT_UNDEFINED
1628
1650
 
1629
- const leftOffset = resolveValue(leftPos, nodeWidth)
1630
- const topOffset = resolveValue(topPos, nodeHeight)
1631
- const rightOffset = resolveValue(rightPos, nodeWidth)
1632
- const bottomOffset = resolveValue(bottomPos, nodeHeight)
1651
+ // Yoga resolves percentage position offsets against the content box dimensions
1652
+ const leftOffset = resolveValue(leftPos, absContentBoxW)
1653
+ const topOffset = resolveValue(topPos, absContentBoxH)
1654
+ const rightOffset = resolveValue(rightPos, absContentBoxW)
1655
+ const bottomOffset = resolveValue(bottomPos, absContentBoxH)
1633
1656
 
1634
1657
  // Calculate available size for absolute child using padding box
1635
1658
  const contentW = absPaddingBoxW
@@ -1702,28 +1725,45 @@ function layoutNode(
1702
1725
  const childHeight = child.layout.height
1703
1726
 
1704
1727
  // Apply alignment when no explicit position set
1705
- // For absolute children, align-items/justify-content apply when no position offsets
1728
+ // For absolute children, align-items applies on cross axis, justify-content on main axis
1729
+ // Row: X = main axis (justifyContent), Y = cross axis (alignItems)
1730
+ // Column: X = cross axis (alignItems), Y = main axis (justifyContent)
1706
1731
  if (!hasLeft && !hasRight) {
1707
- // No horizontal position - use align-items (for row) or justify-content (for column)
1708
- // Default column direction: cross-axis is horizontal, use alignItems
1709
- let alignment = style.alignItems
1710
- if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1711
- alignment = childStyle.alignSelf
1712
- }
1713
- const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
1714
- switch (alignment) {
1715
- case C.ALIGN_CENTER:
1716
- childX = childMarginLeft + freeSpaceX / 2
1717
- break
1718
- case C.ALIGN_FLEX_END:
1719
- childX = childMarginLeft + freeSpaceX
1720
- break
1721
- case C.ALIGN_STRETCH:
1722
- // Stretch: already handled by setting width to fill
1723
- break
1724
- default: // FLEX_START
1725
- childX = childMarginLeft
1726
- break
1732
+ if (isRow) {
1733
+ // Row: X is main axis, use justifyContent
1734
+ const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
1735
+ switch (style.justifyContent) {
1736
+ case C.JUSTIFY_CENTER:
1737
+ childX = childMarginLeft + freeSpaceX / 2
1738
+ break
1739
+ case C.JUSTIFY_FLEX_END:
1740
+ childX = childMarginLeft + freeSpaceX
1741
+ break
1742
+ default: // FLEX_START
1743
+ childX = childMarginLeft
1744
+ break
1745
+ }
1746
+ } else {
1747
+ // Column: X is cross axis, use alignItems/alignSelf
1748
+ let alignment = style.alignItems
1749
+ if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1750
+ alignment = childStyle.alignSelf
1751
+ }
1752
+ const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
1753
+ switch (alignment) {
1754
+ case C.ALIGN_CENTER:
1755
+ childX = childMarginLeft + freeSpaceX / 2
1756
+ break
1757
+ case C.ALIGN_FLEX_END:
1758
+ childX = childMarginLeft + freeSpaceX
1759
+ break
1760
+ case C.ALIGN_STRETCH:
1761
+ // Stretch: already handled by setting width to fill
1762
+ break
1763
+ default: // FLEX_START
1764
+ childX = childMarginLeft
1765
+ break
1766
+ }
1727
1767
  }
1728
1768
  } else if (!hasLeft && hasRight) {
1729
1769
  // Position from right edge
@@ -1734,19 +1774,41 @@ function layoutNode(
1734
1774
  }
1735
1775
 
1736
1776
  if (!hasTop && !hasBottom) {
1737
- // No vertical position - use justify-content (for row) or align-items (for column)
1738
- // Default column direction: main-axis is vertical, use justifyContent
1739
- const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
1740
- switch (style.justifyContent) {
1741
- case C.JUSTIFY_CENTER:
1742
- childY = childMarginTop + freeSpaceY / 2
1743
- break
1744
- case C.JUSTIFY_FLEX_END:
1745
- childY = childMarginTop + freeSpaceY
1746
- break
1747
- default: // FLEX_START
1748
- childY = childMarginTop
1749
- break
1777
+ if (isRow) {
1778
+ // Row: Y is cross axis, use alignItems/alignSelf
1779
+ let alignment = style.alignItems
1780
+ if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1781
+ alignment = childStyle.alignSelf
1782
+ }
1783
+ const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
1784
+ switch (alignment) {
1785
+ case C.ALIGN_CENTER:
1786
+ childY = childMarginTop + freeSpaceY / 2
1787
+ break
1788
+ case C.ALIGN_FLEX_END:
1789
+ childY = childMarginTop + freeSpaceY
1790
+ break
1791
+ case C.ALIGN_STRETCH:
1792
+ // Stretch: already handled by setting height to fill
1793
+ break
1794
+ default: // FLEX_START
1795
+ childY = childMarginTop
1796
+ break
1797
+ }
1798
+ } else {
1799
+ // Column: Y is main axis, use justifyContent
1800
+ const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
1801
+ switch (style.justifyContent) {
1802
+ case C.JUSTIFY_CENTER:
1803
+ childY = childMarginTop + freeSpaceY / 2
1804
+ break
1805
+ case C.JUSTIFY_FLEX_END:
1806
+ childY = childMarginTop + freeSpaceY
1807
+ break
1808
+ default: // FLEX_START
1809
+ childY = childMarginTop
1810
+ break
1811
+ }
1750
1812
  }
1751
1813
  } else if (!hasTop && hasBottom) {
1752
1814
  // Position from bottom edge
@@ -1769,14 +1831,12 @@ function layoutNode(
1769
1831
  // so the public API remains consistent between versions.
1770
1832
 
1771
1833
  export let layoutNodeCalls = 0
1772
- export let resolveEdgeCalls = 0
1773
1834
  export let layoutSizingCalls = 0
1774
1835
  export let layoutPositioningCalls = 0
1775
1836
  export let layoutCacheHits = 0
1776
1837
 
1777
1838
  export function resetLayoutStats(): void {
1778
1839
  layoutNodeCalls = 0
1779
- resolveEdgeCalls = 0
1780
1840
  layoutSizingCalls = 0
1781
1841
  layoutPositioningCalls = 0
1782
1842
  layoutCacheHits = 0
@@ -109,6 +109,18 @@ export class Node {
109
109
  * ```
110
110
  */
111
111
  insertChild(child: Node, index: number): void {
112
+ // Cycle guard: prevent self-insertion or insertion of an ancestor
113
+ if (child === this) {
114
+ throw new Error("Cannot insert a node as a child of itself")
115
+ }
116
+ let ancestor: Node | null = this._parent
117
+ while (ancestor !== null) {
118
+ if (ancestor === child) {
119
+ throw new Error("Cannot insert an ancestor as a child (would create a cycle)")
120
+ }
121
+ ancestor = ancestor._parent
122
+ }
123
+
112
124
  if (child._parent !== null) {
113
125
  child._parent.removeChild(child)
114
126
  }
@@ -152,6 +164,19 @@ export class Node {
152
164
  this._baselineFunc = null
153
165
  }
154
166
 
167
+ /**
168
+ * Free this node and all descendants recursively.
169
+ * Each node is detached from its parent and cleaned up.
170
+ */
171
+ freeRecursive(): void {
172
+ // Free children first (leaves to root)
173
+ const children = [...this._children]
174
+ for (const child of children) {
175
+ child.freeRecursive()
176
+ }
177
+ this.free()
178
+ }
179
+
155
180
  /**
156
181
  * Dispose the node (calls free)
157
182
  */
@@ -377,6 +402,41 @@ export class Node {
377
402
  return this._layout.height
378
403
  }
379
404
 
405
+ /**
406
+ * Get the computed right edge position after layout (left + width).
407
+ */
408
+ getComputedRight(): number {
409
+ return this._layout.left + this._layout.width
410
+ }
411
+
412
+ /**
413
+ * Get the computed bottom edge position after layout (top + height).
414
+ */
415
+ getComputedBottom(): number {
416
+ return this._layout.top + this._layout.height
417
+ }
418
+
419
+ /**
420
+ * Get the computed padding for a specific edge after layout.
421
+ */
422
+ getComputedPadding(edge: number): number {
423
+ return getEdgeValue(this._style.padding, edge).value
424
+ }
425
+
426
+ /**
427
+ * Get the computed margin for a specific edge after layout.
428
+ */
429
+ getComputedMargin(edge: number): number {
430
+ return getEdgeValue(this._style.margin, edge).value
431
+ }
432
+
433
+ /**
434
+ * Get the computed border width for a specific edge after layout.
435
+ */
436
+ getComputedBorder(edge: number): number {
437
+ return getEdgeBorderValue(this._style.border, edge)
438
+ }
439
+
380
440
  // ============================================================================
381
441
  // Internal Accessors (for layout algorithm)
382
442
  // ============================================================================
package/src/constants.ts CHANGED
@@ -25,6 +25,7 @@ export const ALIGN_STRETCH = 4
25
25
  export const ALIGN_BASELINE = 5
26
26
  export const ALIGN_SPACE_BETWEEN = 6
27
27
  export const ALIGN_SPACE_AROUND = 7
28
+ export const ALIGN_SPACE_EVENLY = 8
28
29
 
29
30
  // Justify
30
31
  export const JUSTIFY_FLEX_START = 0
@@ -64,7 +65,7 @@ export const OVERFLOW_VISIBLE = 0
64
65
  export const OVERFLOW_HIDDEN = 1
65
66
  export const OVERFLOW_SCROLL = 2
66
67
 
67
- // Direction (for RTL support - we only support LTR)
68
+ // Direction (LTR and RTL are both supported)
68
69
  export const DIRECTION_INHERIT = 0
69
70
  export const DIRECTION_LTR = 1
70
71
  export const DIRECTION_RTL = 2
@@ -46,6 +46,7 @@ export {
46
46
  ALIGN_BASELINE,
47
47
  ALIGN_SPACE_BETWEEN,
48
48
  ALIGN_SPACE_AROUND,
49
+ ALIGN_SPACE_EVENLY,
49
50
  // Justify
50
51
  JUSTIFY_FLEX_START,
51
52
  JUSTIFY_CENTER,
@@ -102,7 +103,6 @@ export { createDefaultStyle, createValue } from "./types.js"
102
103
  // Layout stats (for debugging/benchmarking)
103
104
  export {
104
105
  layoutNodeCalls,
105
- resolveEdgeCalls,
106
106
  layoutSizingCalls,
107
107
  layoutPositioningCalls,
108
108
  layoutCacheHits,
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  ALIGN_BASELINE,
47
47
  ALIGN_SPACE_BETWEEN,
48
48
  ALIGN_SPACE_AROUND,
49
+ ALIGN_SPACE_EVENLY,
49
50
  // Justify
50
51
  JUSTIFY_FLEX_START,
51
52
  JUSTIFY_CENTER,
@@ -102,7 +103,6 @@ export { createDefaultStyle, createValue } from "./types.js"
102
103
  // Layout stats (for debugging/benchmarking)
103
104
  export {
104
105
  layoutNodeCalls,
105
- resolveEdgeCalls,
106
106
  layoutSizingCalls,
107
107
  layoutPositioningCalls,
108
108
  layoutCacheHits,
@@ -4,8 +4,9 @@
4
4
  * Pre-allocated arrays for zero-allocation flex-wrap layout,
5
5
  * plus the line-breaking and flex-distribution algorithms.
6
6
  *
7
- * IMPORTANT: Module-level pre-allocated arrays are NOT reentrant.
8
- * Layout is single-threaded; concurrent calculateLayout() calls corrupt shared state.
7
+ * Re-entrancy: A user measureFunc or baselineFunc may synchronously call
8
+ * calculateLayout() on a separate tree. saveLineState/restoreLineState
9
+ * bracket such nested calls to protect the outer pass's scratch arrays.
9
10
  */
10
11
 
11
12
  import * as C from "./constants.js"
@@ -89,6 +90,69 @@ export function growLineArrays(needed: number): void {
89
90
  }
90
91
  }
91
92
 
93
+ // ============================================================================
94
+ // Re-entrancy Support
95
+ // ============================================================================
96
+ // When a measureFunc/baselineFunc synchronously calls calculateLayout() on
97
+ // another tree, we must save and restore the module-level scratch arrays so
98
+ // the outer pass resumes with its own data intact.
99
+
100
+ /**
101
+ * Saved snapshot of all module-level line arrays.
102
+ * Allocated only on re-entrant calls (depth > 0).
103
+ */
104
+ export interface LineStateSave {
105
+ crossSizes: Float64Array<ArrayBuffer>
106
+ crossOffsets: Float64Array<ArrayBuffer>
107
+ lengths: Uint16Array<ArrayBuffer>
108
+ justifyStarts: Float64Array<ArrayBuffer>
109
+ itemSpacings: Float64Array<ArrayBuffer>
110
+ children: Node[][]
111
+ maxLines: number
112
+ }
113
+
114
+ /** Current re-entrancy depth. 0 = outermost (no save needed). */
115
+ let _layoutDepth = 0
116
+
117
+ /**
118
+ * Enter a layout pass. If re-entrant (depth > 0), saves current line state.
119
+ * @returns The saved state (to pass to restoreLineState), or null at depth 0.
120
+ */
121
+ export function enterLayout(): LineStateSave | null {
122
+ const depth = _layoutDepth++
123
+ if (depth === 0) return null // Outermost — no save needed
124
+
125
+ // Save current state (allocates only on re-entrant calls — rare)
126
+ const saved: LineStateSave = {
127
+ crossSizes: _lineCrossSizes.slice(),
128
+ crossOffsets: _lineCrossOffsets.slice(),
129
+ lengths: _lineLengths.slice(),
130
+ justifyStarts: _lineJustifyStarts.slice(),
131
+ itemSpacings: _lineItemSpacings.slice(),
132
+ children: _lineChildren.map((arr) => arr.slice()),
133
+ maxLines: MAX_FLEX_LINES,
134
+ }
135
+ return saved
136
+ }
137
+
138
+ /**
139
+ * Exit a layout pass. If re-entrant, restores saved line state.
140
+ * @param saved - The state returned by enterLayout (null at depth 0).
141
+ */
142
+ export function exitLayout(saved: LineStateSave | null): void {
143
+ _layoutDepth--
144
+ if (!saved) return // Outermost — nothing to restore
145
+
146
+ // Restore saved state
147
+ MAX_FLEX_LINES = saved.maxLines
148
+ _lineCrossSizes = saved.crossSizes
149
+ _lineCrossOffsets = saved.crossOffsets
150
+ _lineLengths = saved.lengths
151
+ _lineJustifyStarts = saved.justifyStarts
152
+ _lineItemSpacings = saved.itemSpacings
153
+ _lineChildren = saved.children
154
+ }
155
+
92
156
  /**
93
157
  * Epsilon value for floating point comparisons in flex distribution.
94
158
  * Used to determine when remaining space is negligible and iteration should stop.
@@ -141,7 +205,10 @@ export function breakIntoLines(
141
205
  if (child.flex.relativeIndex < 0) continue
142
206
 
143
207
  const flex = child.flex
144
- const childMainSize = flex.baseSize + flex.mainMargin
208
+ // CSS spec 9.3.4: line breaking uses the "hypothetical main size" which is
209
+ // the flex base size clamped to min/max, not the unclamped base size.
210
+ const hypotheticalMainSize = Math.max(flex.minMain, Math.min(flex.maxMain, flex.baseSize))
211
+ const childMainSize = hypotheticalMainSize + flex.mainMargin
145
212
  const gapIfNotFirst = lineChildCount > 0 ? mainGap : 0
146
213
 
147
214
  // Check if child fits on current line