From 6302def2f40f208d44a9ff86eeefce6636f94c65 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Fri, 16 Oct 2026 01:05:24 +0200 Subject: [PATCH 1/4] Reuse unconstrained FitContent height measurements A child of a column container is measured with the container's height as a FitContent available height. In a scroll view's content that height grows with the content, so every clean child missed the measurement cache (which matched available sizes exactly) whenever a row was added. Reuse a cached FitContent measurement when it did not fill its available height and still fits the new one. Co-Authored-By: Claude Opus 5.5 --- tests/EventsTest.cpp | 27 +++++++++++++++++++++++++++ yoga/algorithm/CalculateLayout.cpp | 29 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/tests/EventsTest.cpp b/tests/EventsTest.cpp index b852ba2ac6..06b83f8bd6 100644 --- a/tests/EventsTest.cpp +++ b/tests/EventsTest.cpp @@ -227,6 +227,33 @@ TEST_F(EventTest, layout_events_counts_cache_hits_multi_node_layout) { ASSERT_EQ(layoutData.cachedMeasures, 4); } +TEST_F(EventTest, unconstrained_fit_content_height_measure_is_reused) { + // A child measured with a FitContent height that does not constrain it + // (e.g. a row in a scroll view's content, whose height grows) keeps its + // cached measurement when only that available height changes. + auto root = YGNodeNew(); + YGNodeStyleSetWidth(root, 400); + YGNodeStyleSetHeight(root, 1000); + auto container = YGNodeNew(); + YGNodeInsertChild(root, container, 0); + auto content = YGNodeNew(); + YGNodeStyleSetHeight(content, 50); + YGNodeInsertChild(container, content, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + YGNodeStyleSetHeight(root, 1200); + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + LayoutData layoutData = + lastEvent().eventTestData().layoutData; + + ASSERT_EQ(layoutData.measures, 0); + ASSERT_EQ(YGNodeLayoutGetHeight(container), 50); + + YGNodeFreeRecursive(root); +} + TEST_F(EventTest, layout_events_has_max_measure_cache) { auto root = YGNodeNew(); auto a = YGNodeNew(); diff --git a/yoga/algorithm/CalculateLayout.cpp b/yoga/algorithm/CalculateLayout.cpp index e8998c0693..b6f57c82c1 100644 --- a/yoga/algorithm/CalculateLayout.cpp +++ b/yoga/algorithm/CalculateLayout.cpp @@ -35,6 +35,10 @@ namespace facebook::yoga { +// Margin used to tell an unconstrained FitContent measurement from one that +// filled its available size. +constexpr float kUnconstrainedEpsilon = 0.5f; + std::atomic gCurrentGenerationCount(0); static bool hasAutoHorizontalMargin(const Style& style) { @@ -2781,6 +2785,31 @@ bool calculateLayoutInternal( break; } } + // A FitContent height that did not constrain an earlier measurement (the + // measured height plus margin stayed below it) does not change the result + // as long as the new available height still fits that measured height. + // Content in a scroll view is measured with the container's growing + // height, so without this every clean child misses the cache whenever the + // content gets taller. + if (cachedResults == nullptr && + heightSizingMode == SizingMode::FitContent && + yoga::isDefined(availableHeight)) { + const float marginColumn = + node->style().computeMarginForAxis(FlexDirection::Column, ownerWidth); + for (uint32_t i = 0; i < layout->nextCachedMeasurementsIndex; i++) { + const auto& cached = layout->cachedMeasurements[i]; + if (cached.widthSizingMode == widthSizingMode && + yoga::inexactEquals(cached.availableWidth, availableWidth) && + cached.heightSizingMode == SizingMode::FitContent && + yoga::isDefined(cached.availableHeight) && + cached.computedHeight + marginColumn < + cached.availableHeight - kUnconstrainedEpsilon && + cached.computedHeight + marginColumn <= availableHeight) { + cachedResults = &layout->cachedMeasurements[i]; + break; + } + } + } } if (!needToVisitNode && cachedResults != nullptr) { From a852987d42720e1eba8aa72563aba4286d62442a Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Fri, 16 Oct 2026 18:57:11 +0200 Subject: [PATCH 2/4] Don't reuse FitContent measurements that resolved a height percentage A height, min/max height or flex basis in percent anywhere in the subtree is resolved against the available height, so a measurement that didn't fill its available height can still change when that height does (e.g. a 50% child in an auto-height container). Track per node whether its last layout resolved such a percentage, and only reuse an unconstrained FitContent measurement when it didn't. Co-Authored-By: Claude Opus 5.5 --- tests/YGRelayoutTest.cpp | 30 +++++++++++++++++++++++ yoga/algorithm/CalculateLayout.cpp | 38 ++++++++++++++++++++++++++++-- yoga/node/Node.cpp | 4 ++++ yoga/node/Node.h | 16 +++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/tests/YGRelayoutTest.cpp b/tests/YGRelayoutTest.cpp index 593572055f..84c990d263 100644 --- a/tests/YGRelayoutTest.cpp +++ b/tests/YGRelayoutTest.cpp @@ -253,3 +253,33 @@ TEST(YogaTest, has_new_layout_flag_set_static) { YGNodeFreeRecursive(root); } + +TEST( + YogaTest, + percent_height_in_auto_container_relayout_on_root_height_change) { + YGConfigRef config = YGConfigNew(); + + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(root, 400); + YGNodeStyleSetHeight(root, 1000); + + YGNodeRef container = YGNodeNewWithConfig(config); + YGNodeInsertChild(root, container, 0); + + YGNodeRef child = YGNodeNewWithConfig(config); + YGNodeStyleSetHeightPercent(child, 50); + YGNodeInsertChild(container, child, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + ASSERT_FLOAT_EQ(500, YGNodeLayoutGetHeight(container)); + ASSERT_FLOAT_EQ(250, YGNodeLayoutGetHeight(child)); + + YGNodeStyleSetHeight(root, 1200); + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + ASSERT_FLOAT_EQ(600, YGNodeLayoutGetHeight(container)); + ASSERT_FLOAT_EQ(300, YGNodeLayoutGetHeight(child)); + + YGNodeFreeRecursive(root); + + YGConfigFree(config); +} diff --git a/yoga/algorithm/CalculateLayout.cpp b/yoga/algorithm/CalculateLayout.cpp index b6f57c82c1..397dd27c9e 100644 --- a/yoga/algorithm/CalculateLayout.cpp +++ b/yoga/algorithm/CalculateLayout.cpp @@ -41,6 +41,38 @@ constexpr float kUnconstrainedEpsilon = 0.5f; std::atomic gCurrentGenerationCount(0); +// Whether laying out `node` resolves a percentage against a height: a height, +// min/max height or flex basis in percent on the node itself or on a +// descendant that takes part in its layout. Children are read from the flags +// their own layouts left behind, except `display: contents` ones, which are not +// laid out themselves. +static bool resolvesHeightPercentage(const yoga::Node* node) { + const auto& style = node->style(); + if (style.dimension(Dimension::Height).isPercent() || + style.minDimension(Dimension::Height).isPercent() || + style.maxDimension(Dimension::Height).isPercent() || + style.flexBasis().isPercent()) { + return true; + } + for (const auto* child : node->getChildren()) { + switch (child->style().display()) { + case Display::None: + break; + case Display::Contents: + if (resolvesHeightPercentage(child)) { + return true; + } + break; + default: + if (child->resolvesHeightPercentage()) { + return true; + } + break; + } + } + return false; +} + static bool hasAutoHorizontalMargin(const Style& style) { for (const auto direction : {Direction::LTR, Direction::RTL}) { if (style.flexStartMarginIsAuto(FlexDirection::Row, direction) || @@ -2790,8 +2822,9 @@ bool calculateLayoutInternal( // as long as the new available height still fits that measured height. // Content in a scroll view is measured with the container's growing // height, so without this every clean child misses the cache whenever the - // content gets taller. - if (cachedResults == nullptr && + // content gets taller. That doesn't hold if a percentage in the subtree + // is resolved against the available height. + if (cachedResults == nullptr && !node->resolvesHeightPercentage() && heightSizingMode == SizingMode::FitContent && yoga::isDefined(availableHeight)) { const float marginColumn = @@ -2838,6 +2871,7 @@ bool calculateLayoutInternal( layout->lastOwnerDirection = ownerDirection; layout->configVersion = node->getConfig()->getVersion(); + node->setResolvesHeightPercentage(resolvesHeightPercentage(node)); if (cachedResults == nullptr) { layoutMarkerData.maxMeasureCache = std::max( diff --git a/yoga/node/Node.cpp b/yoga/node/Node.cpp index 161c1900ec..8165ee17f3 100644 --- a/yoga/node/Node.cpp +++ b/yoga/node/Node.cpp @@ -33,6 +33,7 @@ Node::Node(Node&& node) noexcept isReferenceBaseline_(node.isReferenceBaseline_), isDirty_(node.isDirty_), alwaysFormsContainingBlock_(node.alwaysFormsContainingBlock_), + resolvesHeightPercentage_(node.resolvesHeightPercentage_), nodeType_(node.nodeType_), context_(node.context_), measureFunc_(node.measureFunc_), @@ -208,6 +209,9 @@ void Node::setDirty(bool isDirty) { return; } isDirty_ = isDirty; + if (isDirty) { + resolvesHeightPercentage_ = true; + } if (isDirty && (dirtiedFunc_ != nullptr)) { dirtiedFunc_(this); } diff --git a/yoga/node/Node.h b/yoga/node/Node.h index 74fd2c20d4..3fa3b56abf 100644 --- a/yoga/node/Node.h +++ b/yoga/node/Node.h @@ -194,6 +194,17 @@ class YG_EXPORT Node : public ::YGNode { return isDirty_; } + /** + * Whether the last layout of this node resolved a percentage against a + * height, on the node itself or on a descendant that takes part in its + * layout. Measurements of such a node depend on the available height even + * when they don't fill it. True until the node has been laid out, and again + * once it is dirtied. + */ + bool resolvesHeightPercentage() const { + return resolvesHeightPercentage_; + } + Style::SizeLength getProcessedDimension(Dimension dimension) const { return processedDimensions_[static_cast(dimension)]; } @@ -232,6 +243,10 @@ class YG_EXPORT Node : public ::YGNode { hasNewLayout_ = hasNewLayout; } + void setResolvesHeightPercentage(bool resolvesHeightPercentage) { + resolvesHeightPercentage_ = resolvesHeightPercentage; + } + void setNodeType(NodeType nodeType) { nodeType_ = nodeType; } @@ -342,6 +357,7 @@ class YG_EXPORT Node : public ::YGNode { bool isReferenceBaseline_ : 1 = false; bool isDirty_ : 1 = true; bool alwaysFormsContainingBlock_ : 1 = false; + bool resolvesHeightPercentage_ : 1 = true; NodeType nodeType_ : bitCount() = NodeType::Default; void* context_ = nullptr; YGMeasureFunc measureFunc_ = nullptr; From 748729fa1cda7576dba2ed4e2e86249d2035a8ad Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Fri, 16 Oct 2026 19:36:44 +0200 Subject: [PATCH 3/4] Don't count percentages below a definite height or in absolute children A percentage below a node with a definite height (in points, or from an aspect ratio and a defined width) resolves against that height, and an absolutely positioned child doesn't affect its container's size, so neither makes a measurement depend on the available height. Stop the flag at such nodes, so containers with e.g. a 100% child inside a fixed-size or aspect-ratio box keep reusing their measurement. Co-Authored-By: Claude Opus 5.5 --- tests/EventsTest.cpp | 66 ++++++++++++++++++++++++++++++ yoga/algorithm/CalculateLayout.cpp | 22 +++++++--- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/tests/EventsTest.cpp b/tests/EventsTest.cpp index 06b83f8bd6..9ea0947daf 100644 --- a/tests/EventsTest.cpp +++ b/tests/EventsTest.cpp @@ -254,6 +254,72 @@ TEST_F(EventTest, unconstrained_fit_content_height_measure_is_reused) { YGNodeFreeRecursive(root); } +TEST_F(EventTest, percent_height_under_definite_height_measure_is_reused) { + // A percentage below a node with a definite height resolves against that + // height, not against the available height of the measured container. + auto root = YGNodeNew(); + YGNodeStyleSetWidth(root, 400); + YGNodeStyleSetHeight(root, 1000); + auto container = YGNodeNew(); + YGNodeInsertChild(root, container, 0); + auto fixedBox = YGNodeNew(); + YGNodeStyleSetHeight(fixedBox, 50); + YGNodeInsertChild(container, fixedBox, 0); + auto fixedBoxChild = YGNodeNew(); + YGNodeStyleSetHeightPercent(fixedBoxChild, 100); + YGNodeInsertChild(fixedBox, fixedBoxChild, 0); + auto aspectRatioBox = YGNodeNew(); + YGNodeStyleSetWidthPercent(aspectRatioBox, 100); + YGNodeStyleSetAspectRatio(aspectRatioBox, 4); + YGNodeInsertChild(container, aspectRatioBox, 1); + auto aspectRatioBoxChild = YGNodeNew(); + YGNodeStyleSetHeightPercent(aspectRatioBoxChild, 100); + YGNodeInsertChild(aspectRatioBox, aspectRatioBoxChild, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + YGNodeStyleSetHeight(root, 1200); + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + LayoutData layoutData = + lastEvent().eventTestData().layoutData; + + ASSERT_EQ(layoutData.measures, 0); + ASSERT_EQ(YGNodeLayoutGetHeight(container), 150); + + YGNodeFreeRecursive(root); +} + +TEST_F(EventTest, absolute_percent_height_child_measure_is_reused) { + // An absolutely positioned child doesn't affect the container's size. + auto root = YGNodeNew(); + YGNodeStyleSetWidth(root, 400); + YGNodeStyleSetHeight(root, 1000); + auto container = YGNodeNew(); + YGNodeInsertChild(root, container, 0); + auto content = YGNodeNew(); + YGNodeStyleSetHeight(content, 50); + YGNodeInsertChild(container, content, 0); + auto overlay = YGNodeNew(); + YGNodeStyleSetPositionType(overlay, YGPositionTypeAbsolute); + YGNodeStyleSetHeightPercent(overlay, 100); + YGNodeInsertChild(container, overlay, 1); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + YGNodeStyleSetHeight(root, 1200); + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + LayoutData layoutData = + lastEvent().eventTestData().layoutData; + + ASSERT_EQ(layoutData.measures, 0); + ASSERT_EQ(YGNodeLayoutGetHeight(container), 50); + ASSERT_EQ(YGNodeLayoutGetHeight(overlay), 50); + + YGNodeFreeRecursive(root); +} + TEST_F(EventTest, layout_events_has_max_measure_cache) { auto root = YGNodeNew(); auto a = YGNodeNew(); diff --git a/yoga/algorithm/CalculateLayout.cpp b/yoga/algorithm/CalculateLayout.cpp index 397dd27c9e..03a34d565a 100644 --- a/yoga/algorithm/CalculateLayout.cpp +++ b/yoga/algorithm/CalculateLayout.cpp @@ -41,11 +41,14 @@ constexpr float kUnconstrainedEpsilon = 0.5f; std::atomic gCurrentGenerationCount(0); -// Whether laying out `node` resolves a percentage against a height: a height, -// min/max height or flex basis in percent on the node itself or on a -// descendant that takes part in its layout. Children are read from the flags -// their own layouts left behind, except `display: contents` ones, which are not -// laid out themselves. +// Whether laying out `node` resolves a percentage against a height it doesn't +// define itself: a height, min/max height or flex basis in percent on the +// node, or on a descendant whose percentage reaches the node's available +// height. Below a node with a definite height (in points, or from an aspect +// ratio and a defined width) percentages resolve against that height, and +// absolutely positioned descendants don't affect the node's size, so neither +// counts. Children are read from the flags their own layouts left behind, +// except `display: contents` ones, which are not laid out themselves. static bool resolvesHeightPercentage(const yoga::Node* node) { const auto& style = node->style(); if (style.dimension(Dimension::Height).isPercent() || @@ -54,7 +57,16 @@ static bool resolvesHeightPercentage(const yoga::Node* node) { style.flexBasis().isPercent()) { return true; } + if (style.dimension(Dimension::Height).isPoints() || + (style.aspectRatio().isDefined() && + (style.dimension(Dimension::Width).isPoints() || + style.dimension(Dimension::Width).isPercent()))) { + return false; + } for (const auto* child : node->getChildren()) { + if (child->style().positionType() == PositionType::Absolute) { + continue; + } switch (child->style().display()) { case Display::None: break; From 566edb937d82a3b6f48b06ff916a44eb8e501712 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 19 Oct 2026 12:40:29 +0200 Subject: [PATCH 4/4] Check the owner size when reusing an unconstrained FitContent measurement The lookup for unconstrained FitContent measurements skipped the owner size check that #2042 added to the other cache lookups. A clean node with a fixed width and percentage padding, margin or width keeps the same available width when its owner's width changes, so a taller owner made it reuse a measurement whose percentages were resolved against the previous width. Co-Authored-By: Claude Opus 5.5 --- tests/YGRelayoutTest.cpp | 33 ++++++++++++++++++++++++++++++ yoga/algorithm/CalculateLayout.cpp | 3 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/YGRelayoutTest.cpp b/tests/YGRelayoutTest.cpp index 0e959c4b5e..04b44f1edb 100644 --- a/tests/YGRelayoutTest.cpp +++ b/tests/YGRelayoutTest.cpp @@ -359,3 +359,36 @@ TEST(YogaTest, percent_min_height_follows_owner_size_on_relayout) { YGConfigFree(config); } + +TEST( + YogaTest, + percent_padding_follows_owner_size_on_relayout_with_taller_fit_content) { + YGConfigRef config = YGConfigNew(); + + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(root, 100); + YGNodeStyleSetHeight(root, 100); + + YGNodeRef root_child0 = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(root_child0, 50); + YGNodeStyleSetPaddingPercent(root_child0, YGEdgeTop, 50); + YGNodeInsertChild(root, root_child0, 0); + + YGNodeRef root_child0_child0 = YGNodeNewWithConfig(config); + YGNodeStyleSetHeight(root_child0_child0, 10); + YGNodeInsertChild(root_child0, root_child0_child0, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + ASSERT_FLOAT_EQ(50, YGNodeLayoutGetPadding(root_child0, YGEdgeTop)); + ASSERT_FLOAT_EQ(60, YGNodeLayoutGetHeight(root_child0)); + + YGNodeStyleSetWidth(root, 60); + YGNodeStyleSetHeight(root, 120); + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + ASSERT_FLOAT_EQ(30, YGNodeLayoutGetPadding(root_child0, YGEdgeTop)); + ASSERT_FLOAT_EQ(40, YGNodeLayoutGetHeight(root_child0)); + + YGNodeFreeRecursive(root); + + YGConfigFree(config); +} diff --git a/yoga/algorithm/CalculateLayout.cpp b/yoga/algorithm/CalculateLayout.cpp index f71a133e24..241527e72a 100644 --- a/yoga/algorithm/CalculateLayout.cpp +++ b/yoga/algorithm/CalculateLayout.cpp @@ -2872,7 +2872,8 @@ bool calculateLayoutInternal( yoga::isDefined(cached.availableHeight) && cached.computedHeight + marginColumn < cached.availableHeight - kUnconstrainedEpsilon && - cached.computedHeight + marginColumn <= availableHeight) { + cached.computedHeight + marginColumn <= availableHeight && + isCachedForOwnerSize(node, cached, ownerWidth, ownerHeight)) { cachedResults = &layout->cachedMeasurements[i]; break; }