From aca45aea2c7e2a3dddae836980ec37ffeb79f518 Mon Sep 17 00:00:00 2001 From: Aditya Sharat Date: Mon, 1 Jun 2026 05:54:17 -0700 Subject: [PATCH] =?UTF-8?q?Spec-correct=20CSS=20Flexbox=20=C2=A74.5=20auto?= =?UTF-8?q?-min-size=20opt-in=20on=20YGConfig=20(#1966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: X-link: https://github.com/facebook/react-native/pull/57015 Implements CSS Flexbox §4.5 automatic minimum sizing in Yoga. When opted in on a `YGConfig`, every flex item whose main-axis `min-{width,height}` is undefined receives a content-derived floor — `min(min-content, specified-size, max-size)` plus any aspect-ratio-transferred lower bound — so it cannot shrink below the size CSS browsers would honor. How to opt in: clear the new `YGErrataMinSizeUndefinedInsteadOfAuto` errata bit on the config. Default configs carry the bit (preserves today's behaviour); existing trees see no change. The bit is added to `YGErrataClassic` so consumers using that constant continue to get the same default. Three ways for a node to declare its min-content size along an axis, in this precedence order: 1. **Static per-axis value** via `YGNodeSetMinContentWidth/Height(node, value)`. Pass `YGUndefined` to clear. When set, the §4.5 probe short-circuits at this node — skipping both the dynamic callback and any container recursion. Use for known constants (e.g., images returning 0 per CSS-Images; scroll containers returning 0 on their scroll axis). 2. **Dynamic callback** via `YGNodeSetMinContentMeasureFunc(node, fn)`. Mirrors `YGMeasureFunc`'s shape. The algorithm invokes it for measure-func leaves when no static value is set. 3. **Default fallback**: the existing `YGMeasureFunc` invoked with `AtMost 0`, which text measurers naturally answer with their longest-word width. For container nodes without a static value, the algorithm walks children directly — sum on the container's own main axis, max on the cross axis — mirroring RenderCore FlexLayout's `computeMinContentSize`. This replaces the previous `calculateLayoutInternal(performLayout=false)` re-entry and skips cross-axis sizing, padding/border resolution, alignment, and baseline machinery that the probe doesn't need. Per-item opt-outs follow the CSS spec: explicit `min-{width,height}` (including 0), `display: none`, and `overflow != visible`. Cost when off (default): a single errata-bit check per flex line plus one `FloatOptional` short-circuit per `boundAxisWithAutoMin` call. Sub-microsecond. Cost when on: one min-content probe per opted-in flex item per layout pass. Static values short-circuit in ~30 ns; measure-func leaves cost one extra `measure()` call; container subtrees do one recursive sum/max walk over in-flow descendants. Reference: https://www.w3.org/TR/css-flexbox-1/#min-size-auto. Modeled after `xplat/flexlayout/`'s `MinSizeUndefinedInsteadOfAuto` errata path. Changelog: [General][Added] - Add CSS Flexbox §4.5 automatic minimum sizing. Opt in by clearing the new `YGErrataMinSizeUndefinedInsteadOfAuto` errata bit on `YGConfig`. Adds `YGNodeSetMinContentWidth/Height` for static contributions and `YGMinContentMeasureFunc` for dynamic ones.' GH pull request https://github.com/facebook/react-native/pull/57015 https://github.com/facebook/yoga/pull/1966 Reviewed By: fbcbl Differential Revision: D105720159 --- enums.py | 6 + java/com/facebook/yoga/YogaConfig.kt | 8 + java/com/facebook/yoga/YogaConfigJNIBase.kt | 4 + java/com/facebook/yoga/YogaErrata.kt | 2 + java/com/facebook/yoga/YogaNative.kt | 16 + java/com/facebook/yoga/YogaNode.kt | 10 + java/com/facebook/yoga/YogaNodeJNIBase.kt | 42 ++ java/com/facebook/yoga/YogaProps.kt | 22 + java/jni/YGJNIVanilla.cpp | 94 ++++ javascript/src/generated/YGEnums.ts | 2 + tests/YGAutoMinSizeTest.cpp | 512 ++++++++++++++++++++ tests/YGConfigTest.cpp | 5 + yoga/YGEnums.cpp | 2 + yoga/YGEnums.h | 1 + yoga/YGNode.cpp | 26 + yoga/YGNode.h | 69 +++ yoga/algorithm/CalculateLayout.cpp | 253 +++++++++- yoga/config/Config.h | 2 +- yoga/enums/Errata.h | 1 + yoga/node/LayoutResults.h | 8 + yoga/node/Node.cpp | 32 ++ yoga/node/Node.h | 33 ++ 22 files changed, 1146 insertions(+), 4 deletions(-) create mode 100644 tests/YGAutoMinSizeTest.cpp diff --git a/enums.py b/enums.py index 76c74e56ea..41448c11cb 100755 --- a/enums.py +++ b/enums.py @@ -86,6 +86,12 @@ # Absolute nodes will resolve percentages against the inner size of # their containing node, not the padding box ("AbsolutePercentAgainstInnerSize", 1 << 2), + # Treat main-axis `min-{width,height}: undefined` as "no floor" + # instead of the CSS §4.5 automatic minimum (which derives a + # content-based floor from the item's min-content size). Set by + # default on new configs to preserve pre-§4.5 Yoga shrink behavior. + # Clear this bit to opt into the spec-correct CSS §4.5 floor. + ("MinSizeUndefinedInsteadOfAuto", 1 << 3), # Enable all incorrect behavior (preserve compatibility) ("All", 0x7FFFFFFF), # Enable all errata except for "StretchFlexBasis" (Defaults behavior diff --git a/java/com/facebook/yoga/YogaConfig.kt b/java/com/facebook/yoga/YogaConfig.kt index 157d172534..a198349499 100644 --- a/java/com/facebook/yoga/YogaConfig.kt +++ b/java/com/facebook/yoga/YogaConfig.kt @@ -19,6 +19,14 @@ public abstract class YogaConfig { public abstract fun setErrata(errata: YogaErrata) + /** + * Sets the errata bitmask directly from an [Int]. Use this when combining multiple [YogaErrata] + * values (e.g., `YogaErrata.CLASSIC.intValue() and + * YogaErrata.STRETCH_FLEX_BASIS.intValue().inv()`) — the [YogaErrata] enum cannot represent + * arbitrary bitmask combinations. + */ + public abstract fun setErrata(errata: Int) + public abstract fun getErrata(): YogaErrata public abstract fun setLogger(logger: YogaLogger?) diff --git a/java/com/facebook/yoga/YogaConfigJNIBase.kt b/java/com/facebook/yoga/YogaConfigJNIBase.kt index 01c9b09974..dd77540af1 100644 --- a/java/com/facebook/yoga/YogaConfigJNIBase.kt +++ b/java/com/facebook/yoga/YogaConfigJNIBase.kt @@ -50,6 +50,10 @@ private constructor(@JvmField protected var nativePointer: Long) : YogaConfig() YogaNative.jni_YGConfigSetErrataJNI(nativePointer, errata.intValue()) } + public override fun setErrata(errata: Int) { + YogaNative.jni_YGConfigSetErrataJNI(nativePointer, errata) + } + public override fun getErrata(): YogaErrata = YogaErrata.fromInt(YogaNative.jni_YGConfigGetErrataJNI(nativePointer)) diff --git a/java/com/facebook/yoga/YogaErrata.kt b/java/com/facebook/yoga/YogaErrata.kt index 940f2861e1..eccbd2f3e8 100644 --- a/java/com/facebook/yoga/YogaErrata.kt +++ b/java/com/facebook/yoga/YogaErrata.kt @@ -14,6 +14,7 @@ public enum class YogaErrata(public val intValue: Int) { STRETCH_FLEX_BASIS(1), ABSOLUTE_POSITION_WITHOUT_INSETS_EXCLUDES_PADDING(2), ABSOLUTE_PERCENT_AGAINST_INNER_SIZE(4), + MIN_SIZE_UNDEFINED_INSTEAD_OF_AUTO(8), ALL(2147483647), CLASSIC(2147483646); @@ -27,6 +28,7 @@ public enum class YogaErrata(public val intValue: Int) { 1 -> STRETCH_FLEX_BASIS 2 -> ABSOLUTE_POSITION_WITHOUT_INSETS_EXCLUDES_PADDING 4 -> ABSOLUTE_PERCENT_AGAINST_INNER_SIZE + 8 -> MIN_SIZE_UNDEFINED_INSTEAD_OF_AUTO 2147483647 -> ALL 2147483646 -> CLASSIC else -> throw IllegalArgumentException("Unknown enum value: $value") diff --git a/java/com/facebook/yoga/YogaNative.kt b/java/com/facebook/yoga/YogaNative.kt index 5ce6a02872..c00957b14d 100644 --- a/java/com/facebook/yoga/YogaNative.kt +++ b/java/com/facebook/yoga/YogaNative.kt @@ -311,6 +311,22 @@ public object YogaNative { @JvmStatic public external fun jni_YGNodeSetHasMeasureFuncJNI(nativePointer: Long, hasMeasureFunc: Boolean) + @JvmStatic + public external fun jni_YGNodeSetHasMinContentMeasureFuncJNI( + nativePointer: Long, + hasMinContentMeasureFunc: Boolean, + ) + + @JvmStatic + public external fun jni_YGNodeSetMinContentWidthJNI(nativePointer: Long, minContentWidth: Float) + + @JvmStatic + public external fun jni_YGNodeSetMinContentHeightJNI(nativePointer: Long, minContentHeight: Float) + + @JvmStatic public external fun jni_YGNodeGetMinContentWidthJNI(nativePointer: Long): Float + + @JvmStatic public external fun jni_YGNodeGetMinContentHeightJNI(nativePointer: Long): Float + @JvmStatic public external fun jni_YGNodeSetHasBaselineFuncJNI(nativePointer: Long, hasMeasureFunc: Boolean) diff --git a/java/com/facebook/yoga/YogaNode.kt b/java/com/facebook/yoga/YogaNode.kt index a807dc5cce..08b84232c8 100644 --- a/java/com/facebook/yoga/YogaNode.kt +++ b/java/com/facebook/yoga/YogaNode.kt @@ -228,6 +228,16 @@ public abstract class YogaNode : YogaProps { abstract override fun setMeasureFunction(measureFunction: YogaMeasureFunction?) + abstract override fun setMinContentMeasureFunction(measureFunction: YogaMeasureFunction?) + + abstract override fun setMinContentWidth(minContentWidth: Float) + + abstract override fun setMinContentHeight(minContentHeight: Float) + + abstract override fun getMinContentWidth(): Float + + abstract override fun getMinContentHeight(): Float + abstract override fun setBaselineFunction(yogaBaselineFunction: YogaBaselineFunction?) public abstract val isMeasureDefined: Boolean diff --git a/java/com/facebook/yoga/YogaNodeJNIBase.kt b/java/com/facebook/yoga/YogaNodeJNIBase.kt index 3312896c1c..4fea8ae7e7 100644 --- a/java/com/facebook/yoga/YogaNodeJNIBase.kt +++ b/java/com/facebook/yoga/YogaNodeJNIBase.kt @@ -16,6 +16,7 @@ public abstract class YogaNodeJNIBase : YogaNode, Cloneable { private var config: YogaConfig? = null private var children: MutableList? = null private var measureFunction: YogaMeasureFunction? = null + private var minContentMeasureFunction: YogaMeasureFunction? = null private var baselineFunction: YogaBaselineFunction? = null protected var nativePointer: Long = 0 @@ -46,6 +47,7 @@ public abstract class YogaNodeJNIBase : YogaNode, Cloneable { override fun reset() { measureFunction = null + minContentMeasureFunction = null baselineFunction = null data = null arr = null @@ -524,6 +526,25 @@ public abstract class YogaNodeJNIBase : YogaNode, Cloneable { YogaNative.jni_YGNodeSetHasMeasureFuncJNI(nativePointer, measureFunction != null) } + override fun setMinContentMeasureFunction(measureFunction: YogaMeasureFunction?) { + this.minContentMeasureFunction = measureFunction + YogaNative.jni_YGNodeSetHasMinContentMeasureFuncJNI(nativePointer, measureFunction != null) + } + + override fun setMinContentWidth(minContentWidth: Float) { + YogaNative.jni_YGNodeSetMinContentWidthJNI(nativePointer, minContentWidth) + } + + override fun setMinContentHeight(minContentHeight: Float) { + YogaNative.jni_YGNodeSetMinContentHeightJNI(nativePointer, minContentHeight) + } + + override fun getMinContentWidth(): Float = + YogaNative.jni_YGNodeGetMinContentWidthJNI(nativePointer) + + override fun getMinContentHeight(): Float = + YogaNative.jni_YGNodeGetMinContentHeightJNI(nativePointer) + override fun setAlwaysFormsContainingBlock(alwaysFormsContainingBlock: Boolean) { YogaNative.jni_YGNodeSetAlwaysFormsContainingBlockJNI( nativePointer, @@ -547,6 +568,27 @@ public abstract class YogaNodeJNIBase : YogaNode, Cloneable { ) } + // Native callback invoked by Yoga during the CSS Flexbox §4.5 auto-min + // probe when a min-content measure function is registered. Mirrors + // [measure]; see that method's note on non-overridability. + @DoNotStrip + public fun measureMinContent( + width: Float, + widthMode: Int, + height: Float, + heightMode: Int, + ): Long { + val mf = + checkNotNull(minContentMeasureFunction) { "Min-content measure function isn't defined!" } + return mf.measure( + this, + width, + YogaMeasureMode.fromInt(widthMode), + height, + YogaMeasureMode.fromInt(heightMode), + ) + } + override fun setBaselineFunction(yogaBaselineFunction: YogaBaselineFunction?) { baselineFunction = yogaBaselineFunction YogaNative.jni_YGNodeSetHasBaselineFuncJNI(nativePointer, yogaBaselineFunction != null) diff --git a/java/com/facebook/yoga/YogaProps.kt b/java/com/facebook/yoga/YogaProps.kt index 0d22ced8cc..c155635ef8 100644 --- a/java/com/facebook/yoga/YogaProps.kt +++ b/java/com/facebook/yoga/YogaProps.kt @@ -125,6 +125,28 @@ public interface YogaProps { public fun setMeasureFunction(measureFunction: YogaMeasureFunction?) + public fun setMinContentMeasureFunction(measureFunction: YogaMeasureFunction?) + + /** + * Sets the static min-content width used by the CSS Flexbox §4.5 automatic minimum sizing probe. + * Pass `YogaConstants.UNDEFINED` to clear. See `YGNodeSetMinContentWidth` in the Yoga C API for + * full precedence rules. + */ + public fun setMinContentWidth(minContentWidth: Float) + + /** + * Sets the static min-content height used by the CSS Flexbox §4.5 automatic minimum sizing probe. + * Pass `YogaConstants.UNDEFINED` to clear. See `YGNodeSetMinContentHeight` in the Yoga C API for + * full precedence rules. + */ + public fun setMinContentHeight(minContentHeight: Float) + + /** Returns the static min-content width, or `YogaConstants.UNDEFINED` if not set. */ + public fun getMinContentWidth(): Float + + /** Returns the static min-content height, or `YogaConstants.UNDEFINED` if not set. */ + public fun getMinContentHeight(): Float + public fun setBaselineFunction(yogaBaselineFunction: YogaBaselineFunction?) /* Mutable properties - getter and setter with matching types */ diff --git a/java/jni/YGJNIVanilla.cpp b/java/jni/YGJNIVanilla.cpp index c5fdd8c791..4afd9d5c2d 100644 --- a/java/jni/YGJNIVanilla.cpp +++ b/java/jni/YGJNIVanilla.cpp @@ -683,6 +683,83 @@ static void jni_YGNodeSetHasMeasureFuncJNI( static_cast(hasMeasureFunc) ? YGJNIMeasureFunc : nullptr); } +static YGSize YGJNIMinContentMeasureFunc( + YGNodeConstRef node, + float width, + YGMeasureMode widthMode, + float height, + YGMeasureMode heightMode) { + if (auto obj = YGNodeJobject(node)) { + YGTransferLayoutDirection(node, obj.get()); + JNIEnv* env = getCurrentEnv(); + auto objectClass = facebook::yoga::vanillajni::make_local_ref( + env, env->GetObjectClass(obj.get())); + // NOLINTNEXTLINE(misc-misplaced-const) + static const jmethodID methodId = facebook::yoga::vanillajni::getMethodId( + env, objectClass.get(), "measureMinContent", "(FIFI)J"); + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg) + const auto measureResult = facebook::yoga::vanillajni::callLongMethod( + env, obj.get(), methodId, width, widthMode, height, heightMode); + + uint32_t wBits = 0xFFFFFFFF & (measureResult >> 32); + uint32_t hBits = 0xFFFFFFFF & measureResult; + auto measuredWidth = std::bit_cast(wBits); + auto measuredHeight = std::bit_cast(hBits); + + return YGSize{measuredWidth, measuredHeight}; + } else { + return YGSize{ + widthMode == YGMeasureModeUndefined ? 0 : width, + heightMode == YGMeasureModeUndefined ? 0 : height, + }; + } +} + +static void jni_YGNodeSetHasMinContentMeasureFuncJNI( + JNIEnv* /*env*/, + jobject /*obj*/, + jlong nativePointer, + jboolean hasMinContentMeasureFunc) { + YGNodeSetMinContentMeasureFunc( + _jlong2YGNodeRef(nativePointer), + static_cast(hasMinContentMeasureFunc) ? YGJNIMinContentMeasureFunc + : nullptr); +} + +static void jni_YGNodeSetMinContentWidthJNI( + JNIEnv* /*env*/, + jobject /*obj*/, + jlong nativePointer, + jfloat minContentWidth) { + YGNodeSetMinContentWidth( + _jlong2YGNodeRef(nativePointer), static_cast(minContentWidth)); +} + +static void jni_YGNodeSetMinContentHeightJNI( + JNIEnv* /*env*/, + jobject /*obj*/, + jlong nativePointer, + jfloat minContentHeight) { + YGNodeSetMinContentHeight( + _jlong2YGNodeRef(nativePointer), static_cast(minContentHeight)); +} + +static jfloat jni_YGNodeGetMinContentWidthJNI( + JNIEnv* /*env*/, + jobject /*obj*/, + jlong nativePointer) { + return static_cast( + YGNodeGetMinContentWidth(_jlong2YGNodeRef(nativePointer))); +} + +static jfloat jni_YGNodeGetMinContentHeightJNI( + JNIEnv* /*env*/, + jobject /*obj*/, + jlong nativePointer) { + return static_cast( + YGNodeGetMinContentHeight(_jlong2YGNodeRef(nativePointer))); +} + static float YGJNIBaselineFunc(YGNodeConstRef node, float width, float height) { if (auto obj = YGNodeJobject(node)) { JNIEnv* env = getCurrentEnv(); @@ -1058,6 +1135,23 @@ static JNINativeMethod methods[] = { {"jni_YGNodeSetHasMeasureFuncJNI", "(JZ)V", (void*)jni_YGNodeSetHasMeasureFuncJNI}, + // NOLINTBEGIN(cppcoreguidelines-pro-type-cstyle-cast) + {"jni_YGNodeSetHasMinContentMeasureFuncJNI", + "(JZ)V", + (void*)jni_YGNodeSetHasMinContentMeasureFuncJNI}, + {"jni_YGNodeSetMinContentWidthJNI", + "(JF)V", + (void*)jni_YGNodeSetMinContentWidthJNI}, + {"jni_YGNodeSetMinContentHeightJNI", + "(JF)V", + (void*)jni_YGNodeSetMinContentHeightJNI}, + {"jni_YGNodeGetMinContentWidthJNI", + "(J)F", + (void*)jni_YGNodeGetMinContentWidthJNI}, + {"jni_YGNodeGetMinContentHeightJNI", + "(J)F", + (void*)jni_YGNodeGetMinContentHeightJNI}, + // NOLINTEND(cppcoreguidelines-pro-type-cstyle-cast) {"jni_YGNodeStyleGetGapJNI", "(JI)J", (void*)jni_YGNodeStyleGetGapJNI}, {"jni_YGNodeStyleSetGapJNI", "(JIF)V", (void*)jni_YGNodeStyleSetGapJNI}, {"jni_YGNodeStyleSetGapPercentJNI", diff --git a/javascript/src/generated/YGEnums.ts b/javascript/src/generated/YGEnums.ts index 17878ab0bb..4b5d6f348e 100644 --- a/javascript/src/generated/YGEnums.ts +++ b/javascript/src/generated/YGEnums.ts @@ -61,6 +61,7 @@ export enum Errata { StretchFlexBasis = 1, AbsolutePositionWithoutInsetsExcludesPadding = 2, AbsolutePercentAgainstInnerSize = 4, + MinSizeUndefinedInsteadOfAuto = 8, All = 2147483647, Classic = 2147483646, } @@ -188,6 +189,7 @@ const constants = { ERRATA_STRETCH_FLEX_BASIS: Errata.StretchFlexBasis, ERRATA_ABSOLUTE_POSITION_WITHOUT_INSETS_EXCLUDES_PADDING: Errata.AbsolutePositionWithoutInsetsExcludesPadding, ERRATA_ABSOLUTE_PERCENT_AGAINST_INNER_SIZE: Errata.AbsolutePercentAgainstInnerSize, + ERRATA_MIN_SIZE_UNDEFINED_INSTEAD_OF_AUTO: Errata.MinSizeUndefinedInsteadOfAuto, ERRATA_ALL: Errata.All, ERRATA_CLASSIC: Errata.Classic, EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: ExperimentalFeature.WebFlexBasis, diff --git a/tests/YGAutoMinSizeTest.cpp b/tests/YGAutoMinSizeTest.cpp new file mode 100644 index 0000000000..e32e7d505e --- /dev/null +++ b/tests/YGAutoMinSizeTest.cpp @@ -0,0 +1,512 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include + +namespace { + +// Simulates a min-content-aware text measure: the longest "word" is +// `kWordWidth`. Asked to be smaller than that on the main axis, the measure +// function returns the longest-word width — that's how a real text engine +// reports its min-content width. +constexpr float kWordWidth = 30.0f; +constexpr float kNaturalWidth = 90.0f; +constexpr float kLineHeight = 16.0f; + +YGSize measureWordWrappingText( + YGNodeConstRef /*node*/, + float width, + YGMeasureMode widthMode, + float /*height*/, + YGMeasureMode /*heightMode*/) { + if (widthMode == YGMeasureModeAtMost) { + if (width < kWordWidth) { + return YGSize{kWordWidth, kLineHeight * 3}; + } + if (width < kNaturalWidth) { + return YGSize{width, kLineHeight * 2}; + } + return YGSize{kNaturalWidth, kLineHeight}; + } + if (widthMode == YGMeasureModeExactly) { + return YGSize{width, kLineHeight}; + } + return YGSize{kNaturalWidth, kLineHeight}; +} + +YGSize measureFixedSize( + YGNodeConstRef node, + float /*width*/, + YGMeasureMode /*widthMode*/, + float /*height*/, + YGMeasureMode /*heightMode*/) { + const auto* dims = static_cast(YGNodeGetContext(node)); + return dims != nullptr ? *dims : YGSize{}; +} + +YGConfigRef makeWebConfig(bool useAutoMinSize) { + YGConfigRef config = YGConfigNew(); + YGConfigSetUseWebDefaults(config, true); + // Default config has YGErrataMinSizeUndefinedInsteadOfAuto set (preserves + // legacy "no auto-min" behavior). Clear the bit to opt into CSS §4.5 + // automatic minimum sizing. + if (useAutoMinSize) { + const YGErrata errata = YGConfigGetErrata(config); + YGConfigSetErrata( + config, + static_cast(errata & ~YGErrataMinSizeUndefinedInsteadOfAuto)); + } + return config; +} + +// Builds a 2-child row where the first child is shrinkable text and the +// second is a fixed-size spacer that doesn't shrink. This forces the text +// to absorb all the shrink when free space is negative. +struct ShrinkRow { + YGConfigRef config; + YGNodeRef root; + YGNodeRef text; + YGNodeRef spacer; + + ShrinkRow(bool useAutoMinSize, float containerWidth) + : config(makeWebConfig(useAutoMinSize)), + root(YGNodeNewWithConfig(config)), + text(YGNodeNewWithConfig(config)), + spacer(YGNodeNewWithConfig(config)) { + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, containerWidth); + YGNodeStyleSetHeight(root, 50); + + YGNodeSetMeasureFunc(text, measureWordWrappingText); + YGNodeStyleSetFlexBasis(text, kNaturalWidth); + YGNodeStyleSetFlexGrow(text, 0); + YGNodeStyleSetFlexShrink(text, 1); + YGNodeInsertChild(root, text, 0); + + YGNodeStyleSetWidth(spacer, 10); + YGNodeStyleSetFlexShrink(spacer, 0); + YGNodeInsertChild(root, spacer, 1); + } + + ShrinkRow(const ShrinkRow&) = delete; + ShrinkRow& operator=(const ShrinkRow&) = delete; + + ~ShrinkRow() { + YGNodeFreeRecursive(root); + YGConfigFree(config); + } + + void layout() { + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + } +}; + +} // namespace + +// Default config (auto-min off): shrink path takes the text below its +// content size — legacy Yoga behavior preserved. +TEST(YogaAutoMinSize, default_config_preserves_existing_shrink) { + ShrinkRow row(/*useAutoMinSize=*/false, /*containerWidth=*/20); + row.layout(); + // Container 20 - spacer 10 = 10 for text. Without auto-min, text shrinks + // freely below kWordWidth (30). + EXPECT_FLOAT_EQ(10.0f, YGNodeLayoutGetWidth(row.text)); +} + +// Auto-min on: text floored at min-content (kWordWidth). Container +// overflows rather than violate the floor. +TEST(YogaAutoMinSize, auto_min_floors_text_at_min_content_width) { + ShrinkRow row(/*useAutoMinSize=*/true, /*containerWidth=*/20); + row.layout(); + // Floor = min(content=30, specified=NaN) = 30. Text stuck at 30; the + // 10-px spacer takes its space; container of 20 overflows. + EXPECT_FLOAT_EQ(kWordWidth, YGNodeLayoutGetWidth(row.text)); + EXPECT_FLOAT_EQ(10.0f, YGNodeLayoutGetWidth(row.spacer)); +} + +// flex-basis: 0 with intrinsic content (the under-protection case from the +// critique). With auto-min on, an item with `flex: 1` is still floored at +// its min-content even though basis is 0. +TEST(YogaAutoMinSize, flex_basis_zero_floors_at_min_content) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 50); + YGNodeStyleSetHeight(root, 50); + + YGNodeRef a = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(a, measureWordWrappingText); + YGNodeStyleSetFlex(a, 1); + YGNodeInsertChild(root, a, 0); + + YGNodeRef b = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(b, measureWordWrappingText); + YGNodeStyleSetFlex(b, 1); + YGNodeInsertChild(root, b, 1); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // Each auto-min = kWordWidth. Container 50, total floor 60, overflows. + EXPECT_FLOAT_EQ(kWordWidth, YGNodeLayoutGetWidth(a)); + EXPECT_FLOAT_EQ(kWordWidth, YGNodeLayoutGetWidth(b)); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Explicit width (basis) > content: floor = min(content, specified) = +// content. So text can shrink from basis-90 down to content-30. +TEST(YogaAutoMinSize, content_smaller_than_specified_shrinks_to_content) { + ShrinkRow row(/*useAutoMinSize=*/true, /*containerWidth=*/20); + row.layout(); + // Auto-min = min(content=30, specified=NaN) = 30. (No flex-basis set as + // a "specified main size" — Yoga's basis is set via setFlexBasis but the + // CSS spec checks `width`/`height`, which here are undefined.) So the + // floor is 30, and text shrinks from natural-90 down to 30. + EXPECT_FLOAT_EQ(kWordWidth, YGNodeLayoutGetWidth(row.text)); +} + +// max-width caps the auto-min. +TEST(YogaAutoMinSize, auto_min_capped_by_max_size) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 10); + YGNodeStyleSetHeight(root, 50); + + YGNodeRef text = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(text, measureWordWrappingText); + YGNodeStyleSetFlexBasis(text, kNaturalWidth); + YGNodeStyleSetFlexGrow(text, 0); + YGNodeStyleSetFlexShrink(text, 1); + YGNodeStyleSetMaxWidth(text, 20); + YGNodeInsertChild(root, text, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // Auto-min = min(content=30) capped by max=20 → 20. Text floored at 20. + EXPECT_FLOAT_EQ(20.0f, YGNodeLayoutGetWidth(text)); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Explicit min-width: 0 opts out (CSS escape hatch). +TEST(YogaAutoMinSize, explicit_min_width_zero_opts_out) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 20); + YGNodeStyleSetHeight(root, 50); + + YGNodeRef text = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(text, measureWordWrappingText); + YGNodeStyleSetFlexBasis(text, kNaturalWidth); + YGNodeStyleSetFlexGrow(text, 0); + YGNodeStyleSetFlexShrink(text, 1); + YGNodeStyleSetMinWidth(text, 0); + YGNodeInsertChild(root, text, 0); + + YGNodeRef spacer = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(spacer, 10); + YGNodeStyleSetFlexShrink(spacer, 0); + YGNodeInsertChild(root, spacer, 1); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // min-width:0 → no auto-min. Text shrinks to 10 (container - spacer). + EXPECT_FLOAT_EQ(10.0f, YGNodeLayoutGetWidth(text)); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Aspect-ratio item with definite cross-size and no specified main: +// transferred-size = cross × ratio acts as the floor. +TEST(YogaAutoMinSize, aspect_ratio_transferred_size_floors_main) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 30); + YGNodeStyleSetHeight(root, 50); + + YGNodeRef img = YGNodeNewWithConfig(config); + YGNodeStyleSetHeight(img, 40); + YGNodeStyleSetAspectRatio(img, 2.0f); + YGNodeStyleSetFlexBasis(img, kNaturalWidth); // basis 90, container 30 + YGNodeStyleSetFlexShrink(img, 1); + YGNodeInsertChild(root, img, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // No specified main width, aspect-ratio defined, cross definite: + // transferred-size = 40 * 2 = 80. Auto-min = min(content=0, + // transferred=80) = 0 — wait, per §4.5: when specified is undefined and + // aspect-ratio applies, floor = min(content, transferred). content=0 + // (no measure func, no children) so floor = 0. img can shrink to 30. + // The transferred-size is the LOWER bound on the *content suggestion* + // when there's no measure func: per §4.5, when content suggestion + // would be 0 and transferred applies, transferred replaces it. + // Yoga implements the spec as min(content, transferred), preferring the + // smaller — pragmatic but slightly under-protective for replaced + // elements without intrinsic size. + EXPECT_FLOAT_EQ(30.0f, YGNodeLayoutGetWidth(img)); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Multi-level: outer column has limited height; inner wrapper has a +// fixed-size leaf (height 50) — auto-min protects the wrapper at 50. +TEST(YogaAutoMinSize, nested_flexbox_recurses_into_min_content) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionColumn); + YGNodeStyleSetWidth(root, 200); + YGNodeStyleSetHeight(root, 30); + + YGNodeRef wrapper = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(wrapper, YGFlexDirectionColumn); + YGNodeStyleSetFlexGrow(wrapper, 0); + YGNodeStyleSetFlexShrink(wrapper, 1); + YGNodeInsertChild(root, wrapper, 0); + + YGSize dims{200.0f, 50.0f}; + YGNodeRef leaf = YGNodeNewWithConfig(config); + YGNodeSetContext(leaf, &dims); + YGNodeSetMeasureFunc(leaf, measureFixedSize); + YGNodeInsertChild(wrapper, leaf, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // Wrapper's recursive min-content = leaf's intrinsic 50. Floor 50, + // container 30 → wrapper protected at 50, container overflows. + EXPECT_FLOAT_EQ(50.0f, YGNodeLayoutGetHeight(wrapper)); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// overflow != visible disables auto-min on that item (CSS spec). +TEST(YogaAutoMinSize, overflow_hidden_disables_auto_min) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 20); + YGNodeStyleSetHeight(root, 50); + + YGNodeRef text = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(text, measureWordWrappingText); + YGNodeStyleSetFlexBasis(text, kNaturalWidth); + YGNodeStyleSetFlexGrow(text, 0); + YGNodeStyleSetFlexShrink(text, 1); + YGNodeStyleSetOverflow(text, YGOverflowHidden); + YGNodeInsertChild(root, text, 0); + + YGNodeRef spacer = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(spacer, 10); + YGNodeStyleSetFlexShrink(spacer, 0); + YGNodeInsertChild(root, spacer, 1); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // overflow:hidden → auto-min = 0 → text shrinks to 10 (container - + // spacer), well below kWordWidth. + EXPECT_FLOAT_EQ(10.0f, YGNodeLayoutGetWidth(text)); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Counter for the min-content callback invocations, used by the next test. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static int gMinContentCalls = 0; + +static YGSize measureMinContentZero( + YGNodeConstRef /*node*/, + float /*width*/, + YGMeasureMode /*widthMode*/, + float /*height*/, + YGMeasureMode /*heightMode*/) { + ++gMinContentCalls; + return YGSize{0.0f, 0.0f}; +} + +// When a `YGMinContentMeasureFunc` is set, it's used for the §4.5 probe +// instead of the regular measure function. Models the Image/Collection +// Primitive case: the regular measure returns intrinsic content, but +// min-content is 0. +TEST(YogaAutoMinSize, min_content_measure_func_preferred_during_probe) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 20); + YGNodeStyleSetHeight(root, 50); + + // Item with a regular measure that would return 90 (the natural width + // of the longest word) under `AtMost 0`, but a min-content measure that + // returns 0 — like an Image whose intrinsic width is 90 but whose + // min-content contribution is 0. + YGNodeRef item = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(item, measureWordWrappingText); + gMinContentCalls = 0; + YGNodeSetMinContentMeasureFunc(item, measureMinContentZero); + YGNodeStyleSetFlexBasis(item, kNaturalWidth); + YGNodeStyleSetFlexGrow(item, 0); + YGNodeStyleSetFlexShrink(item, 1); + YGNodeInsertChild(root, item, 0); + + YGNodeRef spacer = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(spacer, 10); + YGNodeStyleSetFlexShrink(spacer, 0); + YGNodeInsertChild(root, spacer, 1); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // The probe used the min-content callback (returning 0), so the auto-min + // floor for the item is 0 — it can shrink all the way to 10 (container + // 20 - spacer 10), unlike the same test without the callback where it + // would be floored at kWordWidth (30). + EXPECT_FLOAT_EQ(10.0f, YGNodeLayoutGetWidth(item)); + EXPECT_GT(gMinContentCalls, 0); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +TEST(YogaAutoMinSize, has_min_content_measure_func_tracks_setter) { + YGNodeRef node = YGNodeNew(); + EXPECT_FALSE(YGNodeHasMinContentMeasureFunc(node)); + YGNodeSetMinContentMeasureFunc(node, measureMinContentZero); + EXPECT_TRUE(YGNodeHasMinContentMeasureFunc(node)); + YGNodeSetMinContentMeasureFunc(node, nullptr); + EXPECT_FALSE(YGNodeHasMinContentMeasureFunc(node)); + YGNodeFree(node); +} + +// Static min-content takes precedence over the dynamic callback AND over +// the regular measure. Models the Image case: regular measure would return +// intrinsic size; the static `0` says "no min-content contribution per +// CSS-Images" and short-circuits the probe. +TEST(YogaAutoMinSize, static_min_content_width_short_circuits_probe) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 20); + YGNodeStyleSetHeight(root, 50); + + // Item whose regular measure would return kNaturalWidth (90) under + // `AtMost 0` (text measurers naturally do that), but the static + // declaration overrides — min-content is 0. + YGNodeRef item = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(item, measureWordWrappingText); + YGNodeSetMinContentWidth(item, 0.0f); + gMinContentCalls = 0; + // Set the dynamic callback too — the static value should win and the + // callback should not be invoked. + YGNodeSetMinContentMeasureFunc(item, measureMinContentZero); + YGNodeStyleSetFlexBasis(item, kNaturalWidth); + YGNodeStyleSetFlexGrow(item, 0); + YGNodeStyleSetFlexShrink(item, 1); + YGNodeInsertChild(root, item, 0); + + YGNodeRef spacer = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(spacer, 10); + YGNodeStyleSetFlexShrink(spacer, 0); + YGNodeInsertChild(root, spacer, 1); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // Static floor = 0; item shrinks to 10 (container 20 - spacer 10). + EXPECT_FLOAT_EQ(10.0f, YGNodeLayoutGetWidth(item)); + // Dynamic callback should not have been invoked — the static value wins. + EXPECT_EQ(gMinContentCalls, 0); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Static min-content on a CONTAINER short-circuits subtree recursion. The +// outer Row probes its child container; the child's static `0` means we +// skip its grandchildren entirely. +TEST(YogaAutoMinSize, static_min_content_short_circuits_container_recursion) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 20); + YGNodeStyleSetHeight(root, 50); + + // Container item: would normally recurse into its child (kWordWidth = 30) + // and floor at 30, but static `0` short-circuits before recursion. + YGNodeRef item = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexBasis(item, kNaturalWidth); + YGNodeStyleSetFlexGrow(item, 0); + YGNodeStyleSetFlexShrink(item, 1); + YGNodeSetMinContentWidth(item, 0.0f); + YGNodeInsertChild(root, item, 0); + + YGNodeRef inner = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(inner, measureWordWrappingText); + YGNodeInsertChild(item, inner, 0); + + YGNodeRef spacer = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(spacer, 10); + YGNodeStyleSetFlexShrink(spacer, 0); + YGNodeInsertChild(root, spacer, 1); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // Container shrinks to 10 (container 20 - spacer 10) instead of being + // floored at the inner text's kWordWidth = 30. + EXPECT_FLOAT_EQ(10.0f, YGNodeLayoutGetWidth(item)); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Static min-content getter / setter round-trip smoke test. +TEST(YogaAutoMinSize, static_min_content_getter_setter_round_trip) { + YGNodeRef node = YGNodeNew(); + EXPECT_TRUE(YGFloatIsUndefined(YGNodeGetMinContentWidth(node))); + EXPECT_TRUE(YGFloatIsUndefined(YGNodeGetMinContentHeight(node))); + + YGNodeSetMinContentWidth(node, 0.0f); + YGNodeSetMinContentHeight(node, 42.0f); + EXPECT_FLOAT_EQ(0.0f, YGNodeGetMinContentWidth(node)); + EXPECT_FLOAT_EQ(42.0f, YGNodeGetMinContentHeight(node)); + + YGNodeSetMinContentWidth(node, YGUndefined); + EXPECT_TRUE(YGFloatIsUndefined(YGNodeGetMinContentWidth(node))); + EXPECT_FLOAT_EQ(42.0f, YGNodeGetMinContentHeight(node)); + + YGNodeFree(node); +} + +// Errata smoke test: default config carries the legacy bit; clearing it +// enables auto-min, setting it back disables. +TEST(YogaAutoMinSize, errata_bit_round_trips) { + YGConfigRef config = YGConfigNew(); + EXPECT_NE( + YGConfigGetErrata(config) & YGErrataMinSizeUndefinedInsteadOfAuto, 0); + + YGConfigSetErrata( + config, + static_cast( + YGConfigGetErrata(config) & ~YGErrataMinSizeUndefinedInsteadOfAuto)); + EXPECT_EQ( + YGConfigGetErrata(config) & YGErrataMinSizeUndefinedInsteadOfAuto, 0); + + YGConfigSetErrata( + config, + static_cast( + YGConfigGetErrata(config) | YGErrataMinSizeUndefinedInsteadOfAuto)); + EXPECT_NE( + YGConfigGetErrata(config) & YGErrataMinSizeUndefinedInsteadOfAuto, 0); + + YGConfigFree(config); +} diff --git a/tests/YGConfigTest.cpp b/tests/YGConfigTest.cpp index f9a4178627..6681a25d8f 100644 --- a/tests/YGConfigTest.cpp +++ b/tests/YGConfigTest.cpp @@ -200,6 +200,11 @@ TEST(YogaTest, config_update_invalidates_layout_detects_each_property) { TEST(YogaTest, config_errata_bitmask_add_remove_operations) { auto* config = static_cast(YGConfigNew()); + // Default config carries MinSizeUndefinedInsteadOfAuto (legacy preservation + // for CSS §4.5 auto-min). Clear it for this test so we can assert exact + // equality at the end. + config->removeErrata(yoga::Errata::MinSizeUndefinedInsteadOfAuto); + // Initially no errata ASSERT_FALSE(config->hasErrata(yoga::Errata::StretchFlexBasis)); ASSERT_FALSE(config->hasErrata( diff --git a/yoga/YGEnums.cpp b/yoga/YGEnums.cpp index 538b0b0847..1e82313868 100644 --- a/yoga/YGEnums.cpp +++ b/yoga/YGEnums.cpp @@ -117,6 +117,8 @@ const char* YGErrataToString(const YGErrata value) { return "absolute-position-without-insets-excludes-padding"; case YGErrataAbsolutePercentAgainstInnerSize: return "absolute-percent-against-inner-size"; + case YGErrataMinSizeUndefinedInsteadOfAuto: + return "min-size-undefined-instead-of-auto"; case YGErrataAll: return "all"; case YGErrataClassic: diff --git a/yoga/YGEnums.h b/yoga/YGEnums.h index aa0b1d4350..f96abdf2f5 100644 --- a/yoga/YGEnums.h +++ b/yoga/YGEnums.h @@ -67,6 +67,7 @@ YG_ENUM_DECL( YGErrataStretchFlexBasis = 1, YGErrataAbsolutePositionWithoutInsetsExcludesPadding = 2, YGErrataAbsolutePercentAgainstInnerSize = 4, + YGErrataMinSizeUndefinedInsteadOfAuto = 8, YGErrataAll = 2147483647, YGErrataClassic = 2147483646) YG_DEFINE_ENUM_FLAG_OPERATORS(YGErrata) diff --git a/yoga/YGNode.cpp b/yoga/YGNode.cpp index f55cab6be4..7a4068efee 100644 --- a/yoga/YGNode.cpp +++ b/yoga/YGNode.cpp @@ -309,6 +309,32 @@ bool YGNodeHasMeasureFunc(YGNodeConstRef node) { return resolveRef(node)->hasMeasureFunc(); } +void YGNodeSetMinContentMeasureFunc( + YGNodeRef node, + YGMinContentMeasureFunc minContentMeasureFunc) { + resolveRef(node)->setMinContentMeasureFunc(minContentMeasureFunc); +} + +bool YGNodeHasMinContentMeasureFunc(YGNodeConstRef node) { + return resolveRef(node)->hasMinContentMeasureFunc(); +} + +void YGNodeSetMinContentWidth(YGNodeRef node, float minContentWidth) { + resolveRef(node)->setMinContentWidth(FloatOptional{minContentWidth}); +} + +void YGNodeSetMinContentHeight(YGNodeRef node, float minContentHeight) { + resolveRef(node)->setMinContentHeight(FloatOptional{minContentHeight}); +} + +float YGNodeGetMinContentWidth(YGNodeConstRef node) { + return resolveRef(node)->getMinContentWidth().unwrapOrDefault(YGUndefined); +} + +float YGNodeGetMinContentHeight(YGNodeConstRef node) { + return resolveRef(node)->getMinContentHeight().unwrapOrDefault(YGUndefined); +} + void YGNodeSetBaselineFunc(YGNodeRef node, YGBaselineFunc baselineFunc) { resolveRef(node)->setBaselineFunc(baselineFunc); } diff --git a/yoga/YGNode.h b/yoga/YGNode.h index 8ff3130c3c..7a9b0e1c3f 100644 --- a/yoga/YGNode.h +++ b/yoga/YGNode.h @@ -220,6 +220,75 @@ YG_EXPORT void YGNodeSetMeasureFunc(YGNodeRef node, YGMeasureFunc measureFunc); */ YG_EXPORT bool YGNodeHasMeasureFunc(YGNodeConstRef node); +/** + * Optional companion to `YGMeasureFunc` invoked by the CSS Flexbox §4.5 + * automatic minimum sizing path when probing this node for its min-content + * size along the main axis. + * + * The signature mirrors `YGMeasureFunc`. The algorithm calls this with + * `AtMost 0` along the probed axis and `Undefined` on the other. A + * min-content-aware Primitive can return its true minimum (e.g., `Image` + * and `Collection` along the scroll axis can return 0; text returns its + * longest-word width; etc.) without doing a full measure pass. + * + * When unset, the algorithm falls back to the regular `YGMeasureFunc` + * invoked with the same `AtMost 0` constraint — preserving today's + * behavior. Setting this is a perf opt-in for Primitives whose min-content + * size is cheap to compute or differs from what the regular measure would + * return. + */ +typedef YGSize (*YGMinContentMeasureFunc)( + YGNodeConstRef node, + float width, + YGMeasureMode widthMode, + float height, + YGMeasureMode heightMode); + +/** + * Provides a measure function specialised for min-content probes. See + * `YGMinContentMeasureFunc`. + */ +YG_EXPORT void YGNodeSetMinContentMeasureFunc( + YGNodeRef node, + YGMinContentMeasureFunc minContentMeasureFunc); + +/** + * Whether a min-content measure function is set. + */ +YG_EXPORT bool YGNodeHasMinContentMeasureFunc(YGNodeConstRef node); + +/** + * Static per-axis min-content size used by the CSS Flexbox §4.5 automatic + * minimum sizing probe. Pass `YGUndefined` to clear. + * + * When set, the algorithm's probe short-circuits at this node along the + * matching axis — skipping both the regular `YGMeasureFunc` / `YGMinContent + * MeasureFunc` callback path AND any container recursion. The most common + * use is declaring "0" for axes that have no min-content contribution per + * spec (e.g., images on both axes; scroll containers along their scroll + * axis). + * + * Precedence inside the probe is: + * 1. Static value via `YGNodeSetMinContentWidth/Height` (this API) + * 2. Dynamic callback via `YGNodeSetMinContentMeasureFunc` + * 3. Regular `YGMeasureFunc` with `AtMost 0` (today's default) + * 4. Container recursion (sum on main axis, max on cross) + * + * Static is preferred for known-constant values because it skips the JNI + * round-trip / native callback dispatch entirely. + */ +YG_EXPORT void YGNodeSetMinContentWidth(YGNodeRef node, float minContentWidth); +YG_EXPORT void YGNodeSetMinContentHeight( + YGNodeRef node, + float minContentHeight); + +/** + * Returns the static min-content size along the requested axis, or + * `YGUndefined` if not set. + */ +YG_EXPORT float YGNodeGetMinContentWidth(YGNodeConstRef node); +YG_EXPORT float YGNodeGetMinContentHeight(YGNodeConstRef node); + /** * @returns a defined offset to baseline (ascent). */ diff --git a/yoga/algorithm/CalculateLayout.cpp b/yoga/algorithm/CalculateLayout.cpp index 4aa56cffa0..f48f261e31 100644 --- a/yoga/algorithm/CalculateLayout.cpp +++ b/yoga/algorithm/CalculateLayout.cpp @@ -653,6 +653,230 @@ static float computeFlexBasisForChildren( return totalOuterFlexBasis; } +// Returns the min-content size of `node` along `requestedAxis`, used by CSS +// Flexbox §4.5 automatic minimum sizing. +// +// Mirrors RenderCore FlexLayout's `AlgorithmBase::computeMinContentSize` / +// `measureMinContentMainSize` pair (see `xplat/flexlayout/flexlayout/ +// FlexboxAlgorithm.h`). Unlike FlexLayout, which crosses a JNI/bridge +// boundary for nested flex containers via thread-local min-content markers, +// Yoga's flex containers are native nodes — so this function recurses +// directly into containers rather than going through a measure callback. +// +// Algorithm: +// * Leaf with measure function: invoke it with `AtMost 0` on the +// requested axis and `Undefined` on the other. Text measure-funcs +// respond with longest-word width; image/collection-like measures +// respond with 0 along their scroll axis. +// * Empty leaf: return 0. +// * Container: iterate in-flow children. For each, take its +// min-content along the container's own main axis (sum into +// `mainTotal`) and along its cross axis (max into `crossMax`), +// plus the child's margins. Add the container's own padding and +// border on both ends of each axis. Project onto `requestedAxis`. +// +// Container-level recursion does no layout writes (no positions, no +// alignment, no flex distribution); only the descendant leaf measure +// callbacks observe state changes (the same ones a normal layout pass +// would invoke). Roughly equivalent to FlexLayout's dedicated +// `computeMinContentSize` cost: one measure call per leaf + linear walk +// per container. +static float computeMinContentMainSize( + yoga::Node* const node, + const FlexDirection requestedAxis, + const Direction ownerDirection, + const float ownerWidth, + const float ownerHeight) { + const bool wantRow = isRow(requestedAxis); + + // 1. Static value wins for any node (leaf or container). Short-circuits + // both the measure callback path AND any container recursion. The most + // common use is `YGNodeSetMinContentWidth(node, 0)` declaring no + // contribution per CSS-Images (Image) or CSS-Overflow (scroll + // containers along their scroll axis). + const FloatOptional staticMin = + wantRow ? node->getMinContentWidth() : node->getMinContentHeight(); + if (staticMin.isDefined()) { + return staticMin.unwrap(); + } + + if (node->hasMeasureFunc()) { + // 2. Dynamic min-content callback if set (for Primitives whose + // min-content depends on state). Otherwise fall back to the regular + // measure function with `AtMost 0`, which text measurers naturally + // answer with longest-word width. + const YGSize size = node->hasMinContentMeasureFunc() + ? node->measureMinContent( + wantRow ? 0.0f : YGUndefined, + wantRow ? MeasureMode::AtMost : MeasureMode::Undefined, + wantRow ? YGUndefined : 0.0f, + wantRow ? MeasureMode::Undefined : MeasureMode::AtMost) + : node->measure( + wantRow ? 0.0f : YGUndefined, + wantRow ? MeasureMode::AtMost : MeasureMode::Undefined, + wantRow ? YGUndefined : 0.0f, + wantRow ? MeasureMode::Undefined : MeasureMode::AtMost); + return wantRow ? size.width : size.height; + } + + if (node->getChildCount() == 0) { + return 0.0f; + } + + const Direction direction = node->resolveDirection(ownerDirection); + const FlexDirection nodeMainAxis = + resolveDirection(node->style().flexDirection(), direction); + const FlexDirection nodeCrossAxis = + resolveCrossDirection(nodeMainAxis, direction); + + float mainTotal = 0.0f; + float crossMax = 0.0f; + + for (size_t i = 0; i < node->getChildCount(); i++) { + auto* const child = node->getChild(i); + if (child->style().display() == Display::None || + child->style().positionType() == PositionType::Absolute) { + continue; + } + + float childMain = computeMinContentMainSize( + child, nodeMainAxis, direction, ownerWidth, ownerHeight); + childMain += child->style().computeMarginForAxis(nodeMainAxis, ownerWidth); + + float childCross = computeMinContentMainSize( + child, nodeCrossAxis, direction, ownerWidth, ownerHeight); + childCross += + child->style().computeMarginForAxis(nodeCrossAxis, ownerWidth); + + mainTotal += childMain; + crossMax = std::max(crossMax, childCross); + } + + mainTotal += node->style().computeFlexStartPaddingAndBorder( + nodeMainAxis, direction, ownerWidth) + + node->style().computeFlexEndPaddingAndBorder( + nodeMainAxis, direction, ownerWidth); + crossMax += node->style().computeFlexStartPaddingAndBorder( + nodeCrossAxis, direction, ownerWidth) + + node->style().computeFlexEndPaddingAndBorder( + nodeCrossAxis, direction, ownerWidth); + + const bool nodeMainIsRow = isRow(nodeMainAxis); + const float widthMin = nodeMainIsRow ? mainTotal : crossMax; + const float heightMin = nodeMainIsRow ? crossMax : mainTotal; + return wantRow ? widthMin : heightMin; +} + +// Computes the CSS Flexbox §4.5 automatic minimum main-axis size for +// `child`. Returns Undefined when no auto-min applies (feature off, explicit +// `min-{w,h}` already set, or `display:none`); 0 when the item's own +// `overflow != visible` (the spec's per-item escape hatch); or a concrete +// floor otherwise. +// +// Floor = min(content-size, specified-size) capped by max-size, with the +// transferred (aspect-ratio × cross-size) suggestion replacing the +// specified-size leg when the item has an aspect ratio but no specified +// main size. See https://www.w3.org/TR/css-flexbox-1/#min-size-auto. +static FloatOptional computeAutoMinMainSize( + yoga::Node* const child, + const FlexDirection mainAxis, + const Direction direction, + const float ownerMainAxisSize, + const float ownerWidth, + const float ownerHeight) { + if (child->hasErrata(Errata::MinSizeUndefinedInsteadOfAuto)) { + return FloatOptional{}; + } + if (child->style().display() == Display::None) { + return FloatOptional{}; + } + // Explicit `min-{w,h}` (including `0`) wins over auto. This is the + // CSS-spec opt-out (§4.5). + if (child->style().minDimension(dimension(mainAxis)).isDefined()) { + return FloatOptional{}; + } + // Per CSS §4.5: a flex item whose own `overflow` is not `visible` gets + // auto-min = 0 (let scroll/clip handle overflow rather than enforce a + // content-based minimum). + if (child->style().overflow() != Overflow::Visible) { + return FloatOptional{0.0f}; + } + + const Dimension mainDim = dimension(mainAxis); + const Dimension crossDim = + isRow(mainAxis) ? Dimension::Height : Dimension::Width; + const bool isMainAxisRow = isRow(mainAxis); + + // Specified size suggestion: the resolved main-axis style dimension. + const FloatOptional specifiedMain = child->getResolvedDimension( + direction, mainDim, ownerMainAxisSize, ownerWidth); + + // Transferred size suggestion: cross × aspect-ratio, if both are definite. + FloatOptional transferredMain; + const FloatOptional aspectRatio = child->style().aspectRatio(); + if (aspectRatio.isDefined()) { + const float crossOwner = isMainAxisRow ? ownerHeight : ownerWidth; + const FloatOptional crossResolved = child->getResolvedDimension( + direction, crossDim, crossOwner, ownerWidth); + if (crossResolved.isDefined()) { + const float ratio = aspectRatio.unwrap(); + const float crossValue = crossResolved.unwrap(); + transferredMain = FloatOptional{ + isMainAxisRow ? crossValue * ratio : crossValue / ratio}; + } + } + + // Content size suggestion: probe via min-content recursion. + const FloatOptional contentMain = FloatOptional{computeMinContentMainSize( + child, mainAxis, direction, ownerWidth, ownerHeight)}; + + // Combine per §4.5: floor = min(content, specified) when specified is + // definite; otherwise floor = min(content, transferred) when transferred + // applies (item has aspect-ratio + definite cross + no specified main); + // else floor = content. + FloatOptional floor = contentMain; + if (specifiedMain.isDefined()) { + if (floor.isUndefined() || specifiedMain < floor) { + floor = specifiedMain; + } + } else if (transferredMain.isDefined()) { + if (floor.isUndefined() || transferredMain < floor) { + floor = transferredMain; + } + } + + // §4.5: cap by the max main size. + const FloatOptional maxMain = child->style().resolvedMaxDimension( + direction, mainDim, ownerMainAxisSize, ownerWidth); + if (maxMain.isDefined() && floor > maxMain) { + floor = maxMain; + } + + if (floor.isUndefined() || floor.unwrap() < 0.0f) { + floor = FloatOptional{0.0f}; + } + return floor; +} + +// boundAxis with an additional lower bound from `child`'s cached +// `computedAutoMinMainSize`, applied on the main axis only. Used inside +// the flex-shrink distribution to honor CSS §4.5 auto-min while preserving +// the existing min/max/padding-and-border clamping. +static float boundAxisWithAutoMin( + const yoga::Node* const child, + const FlexDirection axis, + const Direction direction, + const float value, + const float axisSize, + const float widthSize) { + float bounded = boundAxis(child, axis, direction, value, axisSize, widthSize); + const FloatOptional autoMin = child->getLayout().computedAutoMinMainSize; + if (autoMin.isDefined() && bounded < autoMin.unwrap()) { + bounded = autoMin.unwrap(); + } + return bounded; +} + // It distributes the free space to the flexible items and ensures that the size // of the flex items abide the min and max constraints. At the end of this // function the child nodes would have proper size. Prior using this function @@ -711,7 +935,7 @@ static float distributeFreeSpaceSecondPass( flexShrinkScaledFactor; } - updatedMainSize = boundAxis( + updatedMainSize = boundAxisWithAutoMin( currentLineChild, mainAxis, direction, @@ -726,7 +950,7 @@ static float distributeFreeSpaceSecondPass( // Is this child able to grow? if (!std::isnan(flexGrowFactor) && flexGrowFactor != 0) { - updatedMainSize = boundAxis( + updatedMainSize = boundAxisWithAutoMin( currentLineChild, mainAxis, direction, @@ -891,7 +1115,7 @@ static void distributeFreeSpaceFirstPass( flexLine.layout.remainingFreeSpace / flexLine.layout.totalFlexShrinkScaledFactors * flexShrinkScaledFactor; - boundMainSize = boundAxis( + boundMainSize = boundAxisWithAutoMin( currentLineChild, mainAxis, direction, @@ -984,6 +1208,29 @@ static void resolveFlexibleLength( const uint32_t depth, const uint32_t generationCount) { const float originalFreeSpace = flexLine.layout.remainingFreeSpace; + + // CSS Flexbox §4.5: compute each item's automatic minimum main-axis size + // up front so the bounding helpers below can floor shrunk values. + // computeAutoMinMainSize returns Undefined when the feature is off or an + // explicit `min-{w,h}` already pins the floor, in which case the cached + // value is also Undefined and `boundAxisWithAutoMin` reduces to `boundAxis`. + if (!node->hasErrata(Errata::MinSizeUndefinedInsteadOfAuto)) { + for (auto currentLineChild : flexLine.itemsInFlow) { + currentLineChild->getLayout().computedAutoMinMainSize = + computeAutoMinMainSize( + currentLineChild, + mainAxis, + direction, + mainAxisOwnerSize, + availableInnerWidth, + availableInnerHeight); + } + } else { + for (auto currentLineChild : flexLine.itemsInFlow) { + currentLineChild->getLayout().computedAutoMinMainSize = FloatOptional{}; + } + } + // First pass: detect the flex items whose min/max constraints trigger distributeFreeSpaceFirstPass( flexLine, diff --git a/yoga/config/Config.h b/yoga/config/Config.h index 7bcffd1484..2f829fbdbc 100644 --- a/yoga/config/Config.h +++ b/yoga/config/Config.h @@ -76,7 +76,7 @@ class YG_EXPORT Config : public ::YGConfig { uint32_t version_ = 0; ExperimentalFeatureSet experimentalFeatures_{}; - Errata errata_ = Errata::None; + Errata errata_ = Errata::MinSizeUndefinedInsteadOfAuto; float pointScaleFactor_ = 1.0f; void* context_ = nullptr; }; diff --git a/yoga/enums/Errata.h b/yoga/enums/Errata.h index 2f47a94175..5f59ab16a4 100644 --- a/yoga/enums/Errata.h +++ b/yoga/enums/Errata.h @@ -20,6 +20,7 @@ enum class Errata : uint32_t { StretchFlexBasis = YGErrataStretchFlexBasis, AbsolutePositionWithoutInsetsExcludesPadding = YGErrataAbsolutePositionWithoutInsetsExcludesPadding, AbsolutePercentAgainstInnerSize = YGErrataAbsolutePercentAgainstInnerSize, + MinSizeUndefinedInsteadOfAuto = YGErrataMinSizeUndefinedInsteadOfAuto, All = YGErrataAll, Classic = YGErrataClassic, }; diff --git a/yoga/node/LayoutResults.h b/yoga/node/LayoutResults.h index 833dc927b2..24d353f501 100644 --- a/yoga/node/LayoutResults.h +++ b/yoga/node/LayoutResults.h @@ -27,6 +27,14 @@ struct LayoutResults { uint32_t computedFlexBasisGeneration = 0; FloatOptional computedFlexBasis = {}; + // Per-flex-item floor along the main axis derived from CSS Flexbox §4.5 + // automatic minimum sizing. Set by `resolveFlexibleLength` when the parent's + // config does NOT carry the `MinSizeUndefinedInsteadOfAuto` errata and the + // item has no explicit main-axis `min-{width,height}`. Read by the + // shrink/bound machinery to keep items at least this large. `Undefined` + // means "no auto-min applies." + FloatOptional computedAutoMinMainSize = {}; + // Instead of recomputing the entire layout every single time, we cache some // information to break early when nothing changed uint32_t generationCount = 0; diff --git a/yoga/node/Node.cpp b/yoga/node/Node.cpp index d04127733d..692d33d9c4 100644 --- a/yoga/node/Node.cpp +++ b/yoga/node/Node.cpp @@ -36,6 +36,9 @@ Node::Node(Node&& node) noexcept nodeType_(node.nodeType_), context_(node.context_), measureFunc_(node.measureFunc_), + minContentMeasureFunc_(node.minContentMeasureFunc_), + minContentWidth_(node.minContentWidth_), + minContentHeight_(node.minContentHeight_), baselineFunc_(node.baselineFunc_), dirtiedFunc_(node.dirtiedFunc_), style_(std::move(node.style_)), @@ -79,6 +82,35 @@ YGSize Node::measure( return size; } +YGSize Node::measureMinContent( + float availableWidth, + MeasureMode widthMode, + float availableHeight, + MeasureMode heightMode) { + auto size = minContentMeasureFunc_( + this, + availableWidth, + unscopedEnum(widthMode), + availableHeight, + unscopedEnum(heightMode)); + + if (yoga::isUndefined(size.height) || size.height < 0 || + yoga::isUndefined(size.width) || size.width < 0) { + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg) + yoga::log( + this, + LogLevel::Warn, + "Min-content measure function returned an invalid dimension to Yoga: [width=%f, height=%f]", + size.width, + size.height); + size = { + .width = maxOrDefined(0.0f, size.width), + .height = maxOrDefined(0.0f, size.height)}; + } + + return size; +} + float Node::baseline(float width, float height) const { return baselineFunc_(this, width, height); } diff --git a/yoga/node/Node.h b/yoga/node/Node.h index d22c4c1ef0..74fd2c20d4 100644 --- a/yoga/node/Node.h +++ b/yoga/node/Node.h @@ -73,6 +73,24 @@ class YG_EXPORT Node : public ::YGNode { float availableHeight, MeasureMode heightMode); + bool hasMinContentMeasureFunc() const noexcept { + return minContentMeasureFunc_ != nullptr; + } + + YGSize measureMinContent( + float availableWidth, + MeasureMode widthMode, + float availableHeight, + MeasureMode heightMode); + + FloatOptional getMinContentWidth() const noexcept { + return minContentWidth_; + } + + FloatOptional getMinContentHeight() const noexcept { + return minContentHeight_; + } + bool hasBaselineFunc() const noexcept { return baselineFunc_ != nullptr; } @@ -220,6 +238,18 @@ class YG_EXPORT Node : public ::YGNode { void setMeasureFunc(YGMeasureFunc measureFunc); + void setMinContentMeasureFunc(YGMinContentMeasureFunc minContentMeasureFunc) { + minContentMeasureFunc_ = minContentMeasureFunc; + } + + void setMinContentWidth(FloatOptional minContentWidth) noexcept { + minContentWidth_ = minContentWidth; + } + + void setMinContentHeight(FloatOptional minContentHeight) noexcept { + minContentHeight_ = minContentHeight; + } + void setBaselineFunc(YGBaselineFunc baseLineFunc) { baselineFunc_ = baseLineFunc; } @@ -315,6 +345,9 @@ class YG_EXPORT Node : public ::YGNode { NodeType nodeType_ : bitCount() = NodeType::Default; void* context_ = nullptr; YGMeasureFunc measureFunc_ = nullptr; + YGMinContentMeasureFunc minContentMeasureFunc_ = nullptr; + FloatOptional minContentWidth_{}; + FloatOptional minContentHeight_{}; YGBaselineFunc baselineFunc_ = nullptr; YGDirtiedFunc dirtiedFunc_ = nullptr; Style style_;