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/node-zero.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "./types.js"
20
20
  import { setEdgeValue, setEdgeBorder, getEdgeValue, getEdgeBorderValue, traversalStack } from "./utils.js"
21
21
  import { log } from "./logger.js"
22
+ import { getTrace } from "./trace.js"
22
23
 
23
24
  /**
24
25
  * A layout node in the flexbox tree.
@@ -103,6 +104,8 @@ export class Node {
103
104
  lastAvailH: NaN,
104
105
  lastOffsetX: NaN,
105
106
  lastOffsetY: NaN,
107
+ lastAbsX: NaN,
108
+ lastAbsY: NaN,
106
109
  layoutValid: false,
107
110
  lastDir: 0,
108
111
  }
@@ -184,6 +187,18 @@ export class Node {
184
187
  * ```
185
188
  */
186
189
  insertChild(child: Node, index: number): void {
190
+ // Cycle guard: prevent self-insertion or insertion of an ancestor (would create infinite loop)
191
+ if (child === this) {
192
+ throw new Error("Cannot insert a node as a child of itself")
193
+ }
194
+ let ancestor: Node | null = this._parent
195
+ while (ancestor !== null) {
196
+ if (ancestor === child) {
197
+ throw new Error("Cannot insert an ancestor as a child (would create a cycle)")
198
+ }
199
+ ancestor = ancestor._parent
200
+ }
201
+
187
202
  if (child._parent !== null) {
188
203
  child._parent.removeChild(child)
189
204
  }
@@ -240,6 +255,29 @@ export class Node {
240
255
  this._baselineFunc = null
241
256
  }
242
257
 
258
+ /**
259
+ * Free this node and all descendants recursively.
260
+ * Each node is detached from its parent and cleaned up.
261
+ * Uses iterative traversal to avoid stack overflow on deep trees.
262
+ */
263
+ freeRecursive(): void {
264
+ // Collect all descendants first (iterative to avoid stack overflow)
265
+ const nodes: Node[] = []
266
+ traversalStack.length = 0
267
+ traversalStack.push(this)
268
+ while (traversalStack.length > 0) {
269
+ const current = traversalStack.pop() as Node
270
+ nodes.push(current)
271
+ for (const child of current._children) {
272
+ traversalStack.push(child)
273
+ }
274
+ }
275
+ // Free in reverse order (leaves first) to avoid parent.removeChild on already-freed nodes
276
+ for (let i = nodes.length - 1; i >= 0; i--) {
277
+ nodes[i]!.free()
278
+ }
279
+ }
280
+
243
281
  /**
244
282
  * Dispose the node (calls free)
245
283
  */
@@ -349,6 +387,7 @@ export class Node {
349
387
  Node.measureCacheHits++
350
388
  this._measureResult.width = m0.rw
351
389
  this._measureResult.height = m0.rh
390
+ getTrace()?.measureCacheHit(0, w, h, m0.rw, m0.rh)
352
391
  return this._measureResult
353
392
  }
354
393
  const m1 = this._m1
@@ -356,6 +395,7 @@ export class Node {
356
395
  Node.measureCacheHits++
357
396
  this._measureResult.width = m1.rw
358
397
  this._measureResult.height = m1.rh
398
+ getTrace()?.measureCacheHit(0, w, h, m1.rw, m1.rh)
359
399
  return this._measureResult
360
400
  }
361
401
  const m2 = this._m2
@@ -363,6 +403,7 @@ export class Node {
363
403
  Node.measureCacheHits++
364
404
  this._measureResult.width = m2.rw
365
405
  this._measureResult.height = m2.rh
406
+ getTrace()?.measureCacheHit(0, w, h, m2.rw, m2.rh)
366
407
  return this._measureResult
367
408
  }
368
409
  const m3 = this._m3
@@ -370,9 +411,13 @@ export class Node {
370
411
  Node.measureCacheHits++
371
412
  this._measureResult.width = m3.rw
372
413
  this._measureResult.height = m3.rh
414
+ getTrace()?.measureCacheHit(0, w, h, m3.rw, m3.rh)
373
415
  return this._measureResult
374
416
  }
375
417
 
418
+ // Cache miss
419
+ getTrace()?.measureCacheMiss(0, w, h)
420
+
376
421
  // Call actual measure function
377
422
  const result = this._measureFunc(w, wm, h, hm)
378
423
 
@@ -599,8 +644,9 @@ export class Node {
599
644
  this._lastCalcH = availableHeight
600
645
  this._lastCalcDir = direction
601
646
 
602
- const start = Date.now()
603
- const nodeCount = countNodes(this)
647
+ // Only compute debug stats when debug logging is enabled (avoid O(n) traversal in production)
648
+ const start = log.debug ? Date.now() : 0
649
+ const nodeCount = log.debug ? countNodes(this) : 0
604
650
 
605
651
  // Reset measure statistics for this layout pass
606
652
  Node.resetMeasureStats()
@@ -664,6 +710,56 @@ export class Node {
664
710
  return this._layout.height
665
711
  }
666
712
 
713
+ /**
714
+ * Get the computed right edge position after layout (left + width).
715
+ *
716
+ * @returns The right edge position in points
717
+ */
718
+ getComputedRight(): number {
719
+ return this._layout.left + this._layout.width
720
+ }
721
+
722
+ /**
723
+ * Get the computed bottom edge position after layout (top + height).
724
+ *
725
+ * @returns The bottom edge position in points
726
+ */
727
+ getComputedBottom(): number {
728
+ return this._layout.top + this._layout.height
729
+ }
730
+
731
+ /**
732
+ * Get the computed padding for a specific edge after layout.
733
+ * Returns the resolved padding value (percentage and logical edges resolved).
734
+ *
735
+ * @param edge - EDGE_LEFT, EDGE_TOP, EDGE_RIGHT, or EDGE_BOTTOM
736
+ * @returns Padding value in points
737
+ */
738
+ getComputedPadding(edge: number): number {
739
+ return getEdgeValue(this._style.padding, edge).value
740
+ }
741
+
742
+ /**
743
+ * Get the computed margin for a specific edge after layout.
744
+ * Returns the resolved margin value (percentage and logical edges resolved).
745
+ *
746
+ * @param edge - EDGE_LEFT, EDGE_TOP, EDGE_RIGHT, or EDGE_BOTTOM
747
+ * @returns Margin value in points
748
+ */
749
+ getComputedMargin(edge: number): number {
750
+ return getEdgeValue(this._style.margin, edge).value
751
+ }
752
+
753
+ /**
754
+ * Get the computed border width for a specific edge after layout.
755
+ *
756
+ * @param edge - EDGE_LEFT, EDGE_TOP, EDGE_RIGHT, or EDGE_BOTTOM
757
+ * @returns Border width in points
758
+ */
759
+ getComputedBorder(edge: number): number {
760
+ return getEdgeBorderValue(this._style.border, edge)
761
+ }
762
+
667
763
  // ============================================================================
668
764
  // Internal Accessors (for layout algorithm)
669
765
  // ============================================================================
package/src/testing.ts CHANGED
@@ -37,6 +37,7 @@ export interface LayoutResult {
37
37
  export interface BuildTreeResult {
38
38
  root: Node
39
39
  dirtyTargets: Node[]
40
+ direction?: number // DIRECTION_LTR or DIRECTION_RTL, defaults to LTR
40
41
  }
41
42
 
42
43
  // ============================================================================
@@ -121,11 +122,13 @@ export function assertLayoutSanity(node: Node, path = "root"): void {
121
122
  const l = node.getComputedLeft()
122
123
  const t = node.getComputedTop()
123
124
 
124
- if (w < 0) throw new Error(`${path}: width is negative (${w})`)
125
- if (!Number.isFinite(w)) throw new Error(`${path}: width is not finite (${w})`)
126
- if (!Number.isFinite(l)) throw new Error(`${path}: left is not finite (${l})`)
125
+ // Width and height should be non-negative when finite.
126
+ // They can be NaN for auto-sized nodes in unconstrained containers (e.g., absolute children).
127
+ if (Number.isFinite(w) && w < 0) throw new Error(`${path}: width is negative (${w})`)
127
128
  if (Number.isFinite(h) && h < 0) throw new Error(`${path}: height is negative (${h})`)
128
- if (Number.isFinite(t) && t < 0) throw new Error(`${path}: top is negative (${t})`)
129
+ // Position values can be negative (absolute positioning) or non-finite (unconstrained containers).
130
+ // Only check that width is not Infinity (NaN is ok for auto-sized).
131
+ if (w === Infinity || w === -Infinity) throw new Error(`${path}: width is Infinity`)
129
132
 
130
133
  for (let i = 0; i < node.getChildCount(); i++) {
131
134
  assertLayoutSanity(node.getChild(i)!, `${path}[${i}]`)
@@ -142,15 +145,16 @@ export function expectRelayoutMatchesFresh(
142
145
  layoutHeight: number,
143
146
  ): void {
144
147
  // 1. Build, layout, mark dirty, re-layout
145
- const { root, dirtyTargets } = buildTree()
146
- root.calculateLayout(layoutWidth, layoutHeight, DIRECTION_LTR)
148
+ const { root, dirtyTargets, direction: dir } = buildTree()
149
+ const layoutDir = dir ?? DIRECTION_LTR
150
+ root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
147
151
  for (const t of dirtyTargets) t.markDirty()
148
- root.calculateLayout(layoutWidth, layoutHeight, DIRECTION_LTR)
152
+ root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
149
153
  const incremental = getLayout(root)
150
154
 
151
155
  // 2. Build identical fresh tree, layout once
152
156
  const fresh = buildTree()
153
- fresh.root.calculateLayout(layoutWidth, layoutHeight, DIRECTION_LTR)
157
+ fresh.root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
154
158
  const reference = getLayout(fresh.root)
155
159
 
156
160
  // 3. Must be identical — show detailed diff on failure
@@ -168,10 +172,11 @@ export function expectRelayoutMatchesFresh(
168
172
  * Catches non-determinism or state corruption from a single layout pass.
169
173
  */
170
174
  export function expectIdempotent(buildTree: () => BuildTreeResult, layoutWidth: number, layoutHeight: number): void {
171
- const { root } = buildTree()
172
- root.calculateLayout(layoutWidth, layoutHeight, DIRECTION_LTR)
175
+ const { root, direction: dir } = buildTree()
176
+ const layoutDir = dir ?? DIRECTION_LTR
177
+ root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
173
178
  const first = getLayout(root)
174
- root.calculateLayout(layoutWidth, layoutHeight, DIRECTION_LTR)
179
+ root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
175
180
  const second = getLayout(root)
176
181
 
177
182
  const diffs = diffLayouts(first, second)
@@ -187,10 +192,11 @@ export function expectIdempotent(buildTree: () => BuildTreeResult, layoutWidth:
187
192
  * Catches stale cache entries that don't update on constraint change.
188
193
  */
189
194
  export function expectResizeRoundTrip(buildTree: () => BuildTreeResult, widths: number[]): void {
190
- const { root } = buildTree()
195
+ const { root, direction: dir } = buildTree()
196
+ const layoutDir = dir ?? DIRECTION_LTR
191
197
  for (const w of widths) {
192
198
  root.setWidth(w)
193
- root.calculateLayout(w, NaN, DIRECTION_LTR)
199
+ root.calculateLayout(w, NaN, layoutDir)
194
200
  }
195
201
  const incremental = getLayout(root)
196
202
 
@@ -198,7 +204,7 @@ export function expectResizeRoundTrip(buildTree: () => BuildTreeResult, widths:
198
204
  const finalWidth = widths[widths.length - 1]!
199
205
  const fresh = buildTree()
200
206
  fresh.root.setWidth(finalWidth)
201
- fresh.root.calculateLayout(finalWidth, NaN, DIRECTION_LTR)
207
+ fresh.root.calculateLayout(finalWidth, NaN, layoutDir)
202
208
  const reference = getLayout(fresh.root)
203
209
 
204
210
  const diffs = diffLayouts(reference, incremental)
package/src/types.ts CHANGED
@@ -126,6 +126,10 @@ export interface FlexInfo {
126
126
  lastOffsetX: number
127
127
  /** Last offsetY passed to layoutNode */
128
128
  lastOffsetY: number
129
+ /** Last absX passed to layoutNode (affects edge-based rounding) */
130
+ lastAbsX: number
131
+ /** Last absY passed to layoutNode (affects edge-based rounding) */
132
+ lastAbsY: number
129
133
  /** Whether cached layout is valid (fingerprint matched, not dirty) */
130
134
  layoutValid: boolean
131
135
  /** Last direction passed to layoutNode */
@@ -198,32 +202,35 @@ export function createValue(value = 0, unit = 0): Value {
198
202
 
199
203
  /**
200
204
  * Create default style.
205
+ *
206
+ * Comments indicate where Yoga and CSS defaults differ.
207
+ * Flexily follows Yoga defaults for API compatibility.
201
208
  */
202
209
  export function createDefaultStyle(): Style {
203
210
  return {
204
- display: 0, // DISPLAY_FLEX
205
- positionType: 1, // POSITION_TYPE_RELATIVE
211
+ display: 0, // DISPLAY_FLEX (same in CSS and Yoga)
212
+ positionType: 1, // POSITION_TYPE_RELATIVE (same in CSS and Yoga)
206
213
  position: [createValue(), createValue(), createValue(), createValue(), createValue(), createValue()],
207
- flexDirection: 0, // FLEX_DIRECTION_COLUMN (Yoga default, not CSS!)
208
- flexWrap: 0, // WRAP_NO_WRAP
209
- flexGrow: 0,
210
- flexShrink: 0, // Yoga native default (CSS uses 1)
211
- flexBasis: createValue(0, 3), // AUTO
212
- alignItems: 4, // ALIGN_STRETCH
213
- alignSelf: 0, // ALIGN_AUTO
214
- alignContent: 1, // ALIGN_FLEX_START
215
- justifyContent: 0, // JUSTIFY_FLEX_START
216
- width: createValue(0, 3), // AUTO
217
- height: createValue(0, 3), // AUTO
214
+ flexDirection: 2, // FLEX_DIRECTION_ROW CSS default; Yoga defaults to COLUMN
215
+ flexWrap: 0, // WRAP_NO_WRAP (same in CSS and Yoga)
216
+ flexGrow: 0, // (same in CSS and Yoga)
217
+ flexShrink: 0, // Yoga default; CSS defaults to 1
218
+ flexBasis: createValue(0, 3), // AUTO (same in CSS and Yoga)
219
+ alignItems: 4, // ALIGN_STRETCH (same in CSS and Yoga)
220
+ alignSelf: 0, // ALIGN_AUTO (same in CSS and Yoga)
221
+ alignContent: 1, // ALIGN_FLEX_START — Yoga default; CSS defaults to STRETCH
222
+ justifyContent: 0, // JUSTIFY_FLEX_START (same in CSS and Yoga)
223
+ width: createValue(0, 3), // AUTO (same in CSS and Yoga)
224
+ height: createValue(0, 3), // AUTO (same in CSS and Yoga)
218
225
  minWidth: createValue(),
219
226
  minHeight: createValue(),
220
227
  maxWidth: createValue(),
221
228
  maxHeight: createValue(),
222
- aspectRatio: NaN, // undefined by default
229
+ aspectRatio: NaN, // undefined by default (same in CSS and Yoga)
223
230
  margin: [createValue(), createValue(), createValue(), createValue(), createValue(), createValue()],
224
231
  padding: [createValue(), createValue(), createValue(), createValue(), createValue(), createValue()],
225
232
  border: [0, 0, 0, 0, NaN, NaN],
226
233
  gap: [0, 0],
227
- overflow: 0, // OVERFLOW_VISIBLE
234
+ overflow: 0, // OVERFLOW_VISIBLE (same in CSS and Yoga)
228
235
  }
229
236
  }
package/src/utils.ts CHANGED
@@ -180,35 +180,61 @@ export function resolveValue(value: Value, availableSize: number): number {
180
180
  /**
181
181
  * Apply min/max constraints to a size.
182
182
  *
183
- * When size is NaN (auto-sized), min constraints establish a floor.
184
- * This handles the case where a parent has minWidth/maxWidth but no explicit width -
185
- * children need to resolve percentages against the constrained size.
183
+ * CSS behavior:
184
+ * - min: Floor constraint. Does NOT affect children's layout the container expands
185
+ * after shrink-wrap. When size is NaN (auto-sized), min is NOT applied here;
186
+ * the post-shrink-wrap applyMinMax call (Phase 9) handles it.
187
+ * - max: Ceiling constraint. DOES affect children's layout — content wraps/clips
188
+ * within the max. When size is NaN (auto-sized), max constrains the container
189
+ * so children are laid out within the max bound.
190
+ *
191
+ * Percent constraints that can't resolve (available is NaN) are skipped entirely,
192
+ * since resolveValue returns 0 for percent-against-NaN, which would incorrectly
193
+ * clamp sizes to 0.
186
194
  */
187
195
  export function applyMinMax(size: number, min: Value, max: Value, available: number): number {
188
196
  let result = size
189
197
 
190
- if (min.unit !== C.UNIT_UNDEFINED) {
191
- const minValue = resolveValue(min, available)
192
- // Only apply if minValue is valid (not NaN from percent with NaN available)
193
- if (!Number.isNaN(minValue)) {
194
- // When size is NaN (auto-sized), min establishes the floor
195
- if (Number.isNaN(result)) {
196
- result = minValue
197
- } else {
198
- result = Math.max(result, minValue)
198
+ // Apply max first, then min. CSS spec: when min > max, min wins.
199
+ // By applying max before min, Math.max(result, minValue) ensures min dominates.
200
+
201
+ if (max.unit !== C.UNIT_UNDEFINED) {
202
+ // Skip percent max when available is NaN can't resolve meaningfully
203
+ if (max.unit === C.UNIT_PERCENT && Number.isNaN(available)) {
204
+ // Skip: percent against NaN resolves to 0, which would be wrong
205
+ } else {
206
+ const maxValue = resolveValue(max, available)
207
+ if (!Number.isNaN(maxValue)) {
208
+ // Apply max as ceiling even when size is NaN (auto-sized).
209
+ // This constrains children's layout to the max bound.
210
+ // Phase 9 shrink-wrap may reduce it further; the post-shrink-wrap
211
+ // applyMinMax call ensures max is still respected.
212
+ if (Number.isNaN(result)) {
213
+ // For auto-sized nodes, only apply finite max constraints.
214
+ // Infinity means "no real constraint" (e.g., silvery sets
215
+ // maxWidth=Infinity as default) and should not replace NaN.
216
+ if (maxValue !== Infinity) {
217
+ result = maxValue
218
+ }
219
+ } else {
220
+ result = Math.min(result, maxValue)
221
+ }
199
222
  }
200
223
  }
201
224
  }
202
225
 
203
- if (max.unit !== C.UNIT_UNDEFINED) {
204
- const maxValue = resolveValue(max, available)
205
- // Only apply if maxValue is valid (not NaN from percent with NaN available)
206
- if (!Number.isNaN(maxValue)) {
207
- // When size is NaN (auto-sized), max alone doesn't set the size
208
- // (the element should shrink-wrap to content, then be capped by max)
209
- // Only apply max if we have a concrete size to constrain
210
- if (!Number.isNaN(result)) {
211
- result = Math.min(result, maxValue)
226
+ if (min.unit !== C.UNIT_UNDEFINED) {
227
+ // Skip percent min when available is NaN — can't resolve meaningfully
228
+ if (min.unit === C.UNIT_PERCENT && Number.isNaN(available)) {
229
+ // Skip: percent against NaN resolves to 0, which would be wrong
230
+ } else {
231
+ const minValue = resolveValue(min, available)
232
+ if (!Number.isNaN(minValue)) {
233
+ // Only apply min to definite sizes. When size is NaN (auto-sized),
234
+ // skip — the post-shrink-wrap applyMinMax call will floor it.
235
+ if (!Number.isNaN(result)) {
236
+ result = Math.max(result, minValue)
237
+ }
212
238
  }
213
239
  }
214
240
  }