From 023876a584e37ecb5b499444119cfca04aef45b1 Mon Sep 17 00:00:00 2001 From: Aditya Sharat Date: Thu, 8 Oct 2026 12:57:28 -0700 Subject: [PATCH] =?UTF-8?q?Memoise=20=C2=A74.5=20min-content=20per=20node?= =?UTF-8?q?=20and=20axis=20per=20layout=20(#2036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/yoga/pull/2036 X-link: https://github.com/react/react-native/pull/58833 `computeMinContentMainSize` asks every in-flow child of a container for its min-content along both axes, and every flex container's `resolveFlexibleLength` recomputes the automatic minimum size of each item. Nothing was cached, so a leaf with a measure function N containers below a flex item was probed O(2^N) times per call, then again for every ancestor and every layout pass. A text leaf ten containers deep was probed 7,161 times in a single layout. This memoises min-content per node and axis for the current layout generation, following the existing `computedFlexBasisGeneration` pattern: - A leaf memoises its measured min-content, before its own padding and border. That value doesn't depend on the owner, so the measure callback runs at most once per axis per layout. - A container memoises its full min-content along with the `ownerWidth` it was computed against. Percentage padding and margins resolve against that width, and each ancestor probes with its own inner width, so a result is only reused for the same width. Layout results are unchanged; only the number of measure callbacks drops. The canonical Yoga copy and the React Native vendored copy are both updated, and the C++ API snapshots gain the three new `LayoutResults` fields. Changelog: [General][Fixed] - Avoid exponential min-content measurement during automatic minimum sizing of nested flex containers Differential Revision: D123012133 --- tests/YGAutoMinSizeTest.cpp | 119 +++++++++++++++++++++++++++++ yoga/algorithm/CalculateLayout.cpp | 84 +++++++++++++------- yoga/node/LayoutResults.h | 9 +++ 3 files changed, 186 insertions(+), 26 deletions(-) diff --git a/tests/YGAutoMinSizeTest.cpp b/tests/YGAutoMinSizeTest.cpp index 750f6df1c2..92fd4a2c95 100644 --- a/tests/YGAutoMinSizeTest.cpp +++ b/tests/YGAutoMinSizeTest.cpp @@ -539,6 +539,125 @@ TEST(YogaAutoMinSize, static_min_content_short_circuits_container_recursion) { YGConfigFree(config); } +static YGSize measureMinContentWord( + YGNodeConstRef node, + float /*width*/, + YGMeasureMode /*widthMode*/, + float /*height*/, + YGMeasureMode /*heightMode*/) { + ++gMinContentCalls; + const auto* wordWidth = static_cast(YGNodeGetContext(node)); + return YGSize{wordWidth != nullptr ? *wordWidth : kWordWidth, kLineHeight}; +} + +// Container recursion asks every child for both axes, and every ancestor's +// layout probes its children again, so an unmemoized leaf N containers deep is +// probed O(2^N) times per layout. It must be probed at most once per axis. +TEST(YogaAutoMinSize, nested_leaf_probed_at_most_once_per_axis_per_layout) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 20); + YGNodeStyleSetHeight(root, 50); + + YGNodeRef item = YGNodeNewWithConfig(config); + YGNodeInsertChild(root, item, 0); + YGNodeRef parent = item; + for (int depth = 0; depth < 10; ++depth) { + YGNodeRef container = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection( + container, depth % 2 == 0 ? YGFlexDirectionColumn : YGFlexDirectionRow); + YGNodeInsertChild(parent, container, 0); + parent = container; + } + YGNodeRef leaf = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(leaf, measureWordWrappingText); + YGNodeSetMinContentMeasureFunc(leaf, measureMinContentWord); + YGNodeInsertChild(parent, leaf, 0); + + YGNodeRef spacer = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(spacer, 10); + YGNodeStyleSetFlexShrink(spacer, 0); + YGNodeInsertChild(root, spacer, 1); + + gMinContentCalls = 0; + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + EXPECT_LE(gMinContentCalls, 2); + // The memoized floor still reaches the outermost item. + EXPECT_FLOAT_EQ(kWordWidth, YGNodeLayoutGetWidth(item)); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// A container's min-content depends on the owner width it is probed with, +// through percentage padding, and each ancestor probes with its own inner +// width. The memo must not hand one ancestor's result to another. +TEST(YogaAutoMinSize, container_min_content_memo_respects_owner_width) { + YGConfigRef config = makeWebConfig(/*useAutoMinSize=*/true); + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(root, YGFlexDirectionRow); + YGNodeStyleSetWidth(root, 200); + YGNodeStyleSetHeight(root, 50); + + YGNodeRef outer = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(outer, YGFlexDirectionRow); + YGNodeStyleSetFlexBasis(outer, 300); + YGNodeInsertChild(root, outer, 0); + + YGNodeRef rootSpacer = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(rootSpacer, 150); + YGNodeStyleSetFlexShrink(rootSpacer, 0); + YGNodeInsertChild(root, rootSpacer, 1); + + YGNodeRef inner = YGNodeNewWithConfig(config); + YGNodeStyleSetFlexDirection(inner, YGFlexDirectionColumn); + YGNodeStyleSetPaddingPercent(inner, YGEdgeLeft, 10); + YGNodeStyleSetPaddingPercent(inner, YGEdgeRight, 10); + YGNodeInsertChild(outer, inner, 0); + + YGNodeRef leaf = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(leaf, measureWordWrappingText); + YGNodeSetMinContentMeasureFunc(leaf, measureMinContentWord); + YGNodeInsertChild(inner, leaf, 0); + + YGNodeRef outerSpacer = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(outerSpacer, 20); + YGNodeStyleSetFlexShrink(outerSpacer, 0); + YGNodeInsertChild(outer, outerSpacer, 1); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + // Root probes inner against 200: 30 + 2 * 20 = 70, flooring outer at 70. + EXPECT_FLOAT_EQ(70.0f, YGNodeLayoutGetWidth(outer)); + // Outer probes inner against 70: 30 + 2 * 7 = 44, so inner takes the 50 + // left beside the spacer. Reusing the root's 70 would give 70. + EXPECT_FLOAT_EQ(50.0f, YGNodeLayoutGetWidth(inner)); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Min-content is memoized for a single layout only: the next layout probes +// again, so a leaf whose min-content changed gets its new floor. +TEST(YogaAutoMinSize, min_content_reprobed_on_next_layout) { + ShrinkRow row(/*useAutoMinSize=*/true, /*containerWidth=*/20); + float wordWidth = kWordWidth; + YGNodeSetContext(row.text, &wordWidth); + YGNodeSetMinContentMeasureFunc(row.text, measureMinContentWord); + row.layout(); + EXPECT_FLOAT_EQ(kWordWidth, YGNodeLayoutGetWidth(row.text)); + + wordWidth = 40.0f; + YGNodeMarkDirty(row.text); + gMinContentCalls = 0; + row.layout(); + + EXPECT_GT(gMinContentCalls, 0); + EXPECT_FLOAT_EQ(40.0f, YGNodeLayoutGetWidth(row.text)); +} + // Static min-content getter / setter round-trip smoke test. TEST(YogaAutoMinSize, static_min_content_getter_setter_round_trip) { YGNodeRef node = YGNodeNew(); diff --git a/yoga/algorithm/CalculateLayout.cpp b/yoga/algorithm/CalculateLayout.cpp index e8998c0693..a3a70f895a 100644 --- a/yoga/algorithm/CalculateLayout.cpp +++ b/yoga/algorithm/CalculateLayout.cpp @@ -850,15 +850,19 @@ static float computeFlexBasisForChildren( // 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. +// would invoke). +// +// Results are memoized per node and axis for the current layout generation +// (see `LayoutResults::minContent`): each leaf's measure callback runs at +// most once per axis per layout, and each container is walked once per +// distinct `ownerWidth`. static float computeMinContentMainSize( yoga::Node* const node, const FlexDirection requestedAxis, const Direction ownerDirection, const float ownerWidth, - const float ownerHeight) { + const float ownerHeight, + const uint32_t generationCount) { const bool wantRow = isRow(requestedAxis); // 1. Static value wins for any node (leaf or container). Short-circuits @@ -872,22 +876,29 @@ static float computeMinContentMainSize( return staticMin.unwrap(); } + LayoutResults& layout = node->getLayout(); + const auto axisIndex = yoga::to_underlying(dimension(requestedAxis)); + 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); + if (layout.minContentGeneration[axisIndex] != generationCount) { + // 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); + layout.minContent[axisIndex] = wantRow ? size.width : size.height; + layout.minContentGeneration[axisIndex] = generationCount; + } // Add the leaf's own padding and border, like the container branch below. const Direction leafDirection = node->resolveDirection(ownerDirection); const float paddingAndBorder = @@ -895,13 +906,18 @@ static float computeMinContentMainSize( requestedAxis, leafDirection, ownerWidth) + node->style().computeFlexEndPaddingAndBorder( requestedAxis, leafDirection, ownerWidth); - return (wantRow ? size.width : size.height) + paddingAndBorder; + return layout.minContent[axisIndex] + paddingAndBorder; } if (node->getChildCount() == 0) { return 0.0f; } + if (layout.minContentGeneration[axisIndex] == generationCount && + yoga::inexactEquals(layout.minContentOwnerWidth[axisIndex], ownerWidth)) { + return layout.minContent[axisIndex]; + } + const Direction direction = node->resolveDirection(ownerDirection); const FlexDirection nodeMainAxis = resolveDirection(node->style().flexDirection(), direction); @@ -919,11 +935,21 @@ static float computeMinContentMainSize( } float childMain = computeMinContentMainSize( - child, nodeMainAxis, direction, ownerWidth, ownerHeight); + child, + nodeMainAxis, + direction, + ownerWidth, + ownerHeight, + generationCount); childMain += child->style().computeMarginForAxis(nodeMainAxis, ownerWidth); float childCross = computeMinContentMainSize( - child, nodeCrossAxis, direction, ownerWidth, ownerHeight); + child, + nodeCrossAxis, + direction, + ownerWidth, + ownerHeight, + generationCount); childCross += child->style().computeMarginForAxis(nodeCrossAxis, ownerWidth); @@ -943,7 +969,11 @@ static float computeMinContentMainSize( const bool nodeMainIsRow = isRow(nodeMainAxis); const float widthMin = nodeMainIsRow ? mainTotal : crossMax; const float heightMin = nodeMainIsRow ? crossMax : mainTotal; - return wantRow ? widthMin : heightMin; + const float minContent = wantRow ? widthMin : heightMin; + layout.minContent[axisIndex] = minContent; + layout.minContentOwnerWidth[axisIndex] = ownerWidth; + layout.minContentGeneration[axisIndex] = generationCount; + return minContent; } // Computes the CSS Flexbox §4.5 automatic minimum main-axis size for @@ -962,7 +992,8 @@ static FloatOptional computeAutoMinMainSize( const Direction direction, const float ownerMainAxisSize, const float ownerWidth, - const float ownerHeight) { + const float ownerHeight, + const uint32_t generationCount) { if (child->hasErrata(Errata::MinSizeUndefinedInsteadOfAuto)) { return FloatOptional{}; } @@ -1007,7 +1038,7 @@ static FloatOptional computeAutoMinMainSize( // Content size suggestion: probe via min-content recursion. const FloatOptional contentMain = FloatOptional{computeMinContentMainSize( - child, mainAxis, direction, ownerWidth, ownerHeight)}; + child, mainAxis, direction, ownerWidth, ownerHeight, generationCount)}; // Combine per §4.5: floor = min(content, specified) when specified is // definite; otherwise floor = min(content, transferred) when transferred @@ -1432,7 +1463,8 @@ static void resolveFlexibleLength( direction, mainAxisOwnerSize, availableInnerWidth, - availableInnerHeight); + availableInnerHeight, + generationCount); } } else { for (auto currentLineChild : flexLine.itemsInFlow) { diff --git a/yoga/node/LayoutResults.h b/yoga/node/LayoutResults.h index 24d353f501..5a0111eb74 100644 --- a/yoga/node/LayoutResults.h +++ b/yoga/node/LayoutResults.h @@ -35,6 +35,15 @@ struct LayoutResults { // means "no auto-min applies." FloatOptional computedAutoMinMainSize = {}; + // Memoized min-content sizes for §4.5, indexed by dimension and valid only + // during layout generation `minContentGeneration`. A leaf stores its measured + // min-content without padding and border, which doesn't depend on the owner. + // A container stores its full min-content, which depends on + // `minContentOwnerWidth` through percentage padding and margins. + std::array minContentGeneration = {}; + std::array minContentOwnerWidth = {{YGUndefined, YGUndefined}}; + std::array minContent = {{YGUndefined, YGUndefined}}; + // Instead of recomputing the entire layout every single time, we cache some // information to break early when nothing changed uint32_t generationCount = 0;