From 1dfefdd5ff314313156ed048124bddbf5f0894d3 Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Wed, 22 Jul 2026 11:54:09 -0700 Subject: [PATCH] Fix flex-growing content sizing in (#57601) Summary: X-link: https://github.com/react/yoga/pull/1997 Preserve available height for flex-growing subtrees nested inside scroll content. Apply optimized flex-basis measurement only when a conservative proof shows that the subtree is height-independent and connected to the nearest scroll ancestor through column-stretch layout edges. Keep authored flex-basis, overflow, padding, absolute-positioning, wrapping, percentage-owner, and box-sizing semantics intact across layout and measurement passes. Gate `boxSizing` cache invalidation with the same Yoga feature so disabling it preserves legacy caching behavior. Add Fantom coverage for native `boxSizing` updates and for rerendering after both `flexBasis` and intrinsic height change. Verify the expected behavior across both `fixYogaFlexBasisFitContentInMainAxis` states. Changelog: [internal] Reviewed By: rubennorte Differential Revision: D112353599 --- .../View-flexBasisFitContent-itest.js | 354 ++++++++++++++++++ .../yoga/yoga/algorithm/CalculateLayout.cpp | 282 ++++++++++---- 2 files changed, 561 insertions(+), 75 deletions(-) diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-flexBasisFitContent-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-flexBasisFitContent-itest.js index d0a7803904a0..e9f1f5fa786b 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-flexBasisFitContent-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-flexBasisFitContent-itest.js @@ -65,6 +65,360 @@ test('auto-height wrapper around a feed ScrollView stays bounded by the viewport expect(feedContentRect.height).toBe(FEED_CONTENT_HEIGHT); }); +test('updates flex-basis and intrinsic height in ScrollView content', () => { + const root = Fantom.createRoot(); + const candidateRef = createRef(); + + const render = (flexBasis: number, intrinsicHeight: number) => { + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + return nullthrows(candidateRef.current).getBoundingClientRect().height; + }; + + expect(render(40, 40)).toBe(40); + + expect(render(80, 20)).toBe(20); +}); + +test('auto-height wrapper gives remaining ScrollView space to a flex-growing body', () => { + const viewportHeight = 200; + const headerHeight = 50; + const root = Fantom.createRoot({ + viewportHeight, + viewportWidth: 100, + }); + + const wrapperRef = createRef(); + const growingBodyRef = createRef(); + + Fantom.runTask(() => { + root.render( + + + + + + , + ); + }); + + const wrapperRect = nullthrows(wrapperRef.current).getBoundingClientRect(); + const growingBodyRect = nullthrows( + growingBodyRef.current, + ).getBoundingClientRect(); + + const layout = { + growingBodyHeight: growingBodyRect.height, + wrapperHeight: wrapperRect.height, + }; + + expect(layout).toEqual({ + growingBodyHeight: viewportHeight - headerHeight, + wrapperHeight: viewportHeight, + }); +}); + +test('positive flex-basis preserves content height in an unbounded ScrollView axis', () => { + const root = Fantom.createRoot({ + viewportHeight: 200, + viewportWidth: 100, + }); + + const wrapperRef = createRef(); + const contentRef = createRef(); + + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + + expect({ + contentHeight: nullthrows(contentRef.current).getBoundingClientRect() + .height, + wrapperHeight: nullthrows(wrapperRef.current).getBoundingClientRect() + .height, + }).toEqual({ + contentHeight: 100, + wrapperHeight: 100, + }); +}); + +test('positive flex-basis larger than content preserves content height in an unbounded ScrollView axis', () => { + const root = Fantom.createRoot({ + viewportHeight: 200, + viewportWidth: 100, + }); + + const wrapperRef = createRef(); + const contentRef = createRef(); + + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + + const layout = { + contentHeight: nullthrows(contentRef.current).getBoundingClientRect() + .height, + wrapperHeight: nullthrows(wrapperRef.current).getBoundingClientRect() + .height, + }; + + expect(layout).toEqual({ + contentHeight: 40, + wrapperHeight: 40, + }); +}); + +test('nested padding respects an exhausted ScrollView height constraint', () => { + const viewportHeight = 100; + const root = Fantom.createRoot({ + viewportHeight, + viewportWidth: 100, + }); + + const wrapperRef = createRef(); + const nestedRef = createRef(); + const paddedRef = createRef(); + + Fantom.runTask(() => { + root.render( + + + + + + + , + ); + }); + + expect({ + nestedHeight: nullthrows(nestedRef.current).getBoundingClientRect().height, + paddedHeight: nullthrows(paddedRef.current).getBoundingClientRect().height, + wrapperHeight: nullthrows(wrapperRef.current).getBoundingClientRect() + .height, + }).toEqual({ + nestedHeight: 0, + paddedHeight: 20, + wrapperHeight: viewportHeight, + }); +}); + +test('clipped ScrollView inside a horizontal pager stays scrollable', () => { + const root = Fantom.createRoot({ + viewportHeight: 150, + viewportWidth: 320, + }); + + const outerScrollRef = createRef(); + const boundedContainerRef = createRef(); + const autoHeightWrapperRef = createRef(); + const innerScrollRef = createRef(); + + Fantom.runTask(() => { + root.render( + + + + + + + + + + , + ); + }); + + const outerScroll = nullthrows(outerScrollRef.current); + const innerScroll = nullthrows(innerScrollRef.current); + const layout = { + autoHeightWrapperHeight: nullthrows( + autoHeightWrapperRef.current, + ).getBoundingClientRect().height, + boundedContainerHeight: nullthrows( + boundedContainerRef.current, + ).getBoundingClientRect().height, + innerContentHeight: innerScroll.scrollHeight, + innerHeight: innerScroll.getBoundingClientRect().height, + innerScrollRange: innerScroll.scrollHeight - innerScroll.clientHeight, + outerHorizontalScrollRange: + outerScroll.scrollWidth - outerScroll.clientWidth, + outerVerticalScrollRange: + outerScroll.scrollHeight - outerScroll.clientHeight, + }; + + expect(layout).toEqual({ + autoHeightWrapperHeight: 150, + boundedContainerHeight: 150, + innerContentHeight: 240, + innerHeight: 150, + innerScrollRange: 90, + outerHorizontalScrollRange: 320, + outerVerticalScrollRange: 0, + }); +}); + +test('percentage padding tracks an updated owner width', () => { + const root = Fantom.createRoot({ + viewportHeight: 300, + viewportWidth: 300, + }); + + function render(updated: boolean) { + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + } + + render(false); + render(true); + + expect( + root + .getRenderedOutput({ + includeLayoutMetrics: true, + props: ['layoutMetrics-contentInsets', 'nativeID'], + }) + .toJSX(), + ).toEqual( + + + + + , + ); +}); + +test('gates native box sizing cache invalidation', () => { + const root = Fantom.createRoot({ + viewportHeight: 280, + viewportWidth: 240, + }); + const scrollRef = createRef(); + const leafRef = createRef(); + + const render = (siblingHeight: number) => { + Fantom.runTask(() => { + root.render( + + + + + + + + + + + + + + , + ); + }); + }; + + render(27); + Fantom.runTask(() => { + nullthrows(leafRef.current).setNativeProps({ + style: {boxSizing: 'content-box'}, + }); + }); + render(40); + + const layoutAfterFirstRerender = { + leafWidth: nullthrows(leafRef.current).getBoundingClientRect().width, + scrollWidth: nullthrows(scrollRef.current).getBoundingClientRect().width, + }; + expect(layoutAfterFirstRerender).toEqual({ + leafWidth: 83, + scrollWidth: 83, + }); + + render(53); + + expect({ + leafWidth: nullthrows(leafRef.current).getBoundingClientRect().width, + scrollWidth: nullthrows(scrollRef.current).getBoundingClientRect().width, + }).toEqual({ + leafWidth: 92, + scrollWidth: 92, + }); +}); + const styles = StyleSheet.create({ feed: { width: VIEWPORT_WIDTH, diff --git a/packages/react-native/ReactCommon/yoga/yoga/algorithm/CalculateLayout.cpp b/packages/react-native/ReactCommon/yoga/yoga/algorithm/CalculateLayout.cpp index e9d62492daae..89fbe3cbbb17 100644 --- a/packages/react-native/ReactCommon/yoga/yoga/algorithm/CalculateLayout.cpp +++ b/packages/react-native/ReactCommon/yoga/yoga/algorithm/CalculateLayout.cpp @@ -6,9 +6,11 @@ */ #include +#include #include #include #include +#include #include #include @@ -35,6 +37,172 @@ namespace facebook::yoga { std::atomic gCurrentGenerationCount(0); +static bool hasAutoHorizontalMargin(const Style& style) { + for (const auto direction : {Direction::LTR, Direction::RTL}) { + if (style.flexStartMarginIsAuto(FlexDirection::Row, direction) || + style.flexEndMarginIsAuto(FlexDirection::Row, direction)) { + return true; + } + } + return false; +} + +static bool isColumnStretchEdge( + const yoga::Node* const owner, + const yoga::Node* const child) { + if (owner == nullptr || child == nullptr) { + return false; + } + const auto& ownerStyle = owner->style(); + const auto& childStyle = child->style(); + const auto childWidth = child->getProcessedDimension(Dimension::Width); + return ownerStyle.display() == Display::Flex && + isColumn(ownerStyle.flexDirection()) && + ownerStyle.flexWrap() == Wrap::NoWrap && + childStyle.positionType() != PositionType::Absolute && + !childStyle.aspectRatio().isDefined() && + (childWidth.isAuto() || childWidth.isUndefined()) && + !hasAutoHorizontalMargin(childStyle) && + resolveChildAlignment(owner, child) == Align::Stretch; +} + +static bool isInColumnStretchScrollSubtree(const yoga::Node* const node) { + auto current = node; + while (current != nullptr) { + auto owner = current->getOwner(); + while (owner != nullptr && owner->style().display() == Display::Contents) { + owner = owner->getOwner(); + } + if (owner == nullptr || !isColumnStretchEdge(owner, current)) { + return false; + } + if (owner->style().overflow() == Overflow::Scroll) { + return true; + } + current = owner; + } + return false; +} + +static bool isNonZeroLength(const Style::Length& length) { + return length.isAuto() || + (length.value().isDefined() && length.value().unwrap() != 0.0f); +} + +static bool hasNonZeroVerticalSpacing(const Style& style) { + constexpr std::array verticalEdges = { + Edge::Top, Edge::Bottom, Edge::Vertical, Edge::All}; + for (const auto edge : verticalEdges) { + if (isNonZeroLength(style.margin(edge)) || + isNonZeroLength(style.padding(edge)) || + isNonZeroLength(style.border(edge))) { + return true; + } + } + return false; +} + +static bool hasPercentageLength(const Style& style) { + constexpr std::array edges = { + Edge::Left, + Edge::Top, + Edge::Right, + Edge::Bottom, + Edge::Start, + Edge::End, + Edge::Horizontal, + Edge::Vertical, + Edge::All}; + for (const auto edge : edges) { + if (style.margin(edge).isPercent() || style.position(edge).isPercent() || + style.padding(edge).isPercent() || style.border(edge).isPercent()) { + return true; + } + } + + constexpr std::array dimensions = { + Dimension::Width, Dimension::Height}; + for (const auto dimension : dimensions) { + if (style.dimension(dimension).isPercent() || + style.minDimension(dimension).isPercent() || + style.maxDimension(dimension).isPercent()) { + return true; + } + } + + return style.flexBasis().isPercent() || + style.gap(Gutter::Column).isPercent() || + style.gap(Gutter::Row).isPercent() || style.gap(Gutter::All).isPercent(); +} + +static bool hasNonZeroFlex(const yoga::Node& node) { + const auto& style = node.style(); + const auto flex = style.flex(); + const auto flexGrow = style.flexGrow(); + const auto flexShrink = style.flexShrink(); + const auto config = node.getConfig(); + const bool canGrow = flexGrow.isDefined() + ? flexGrow.unwrap() != 0.0f + : flex.isDefined() && flex.unwrap() > 0.0f; + const bool canShrink = flexShrink.isDefined() + ? flexShrink.unwrap() != 0.0f + : (config != nullptr && config->useWebDefaults()) || + (flex.isDefined() && flex.unwrap() < 0.0f); + return canGrow || canShrink; +} + +static bool isHeightFitContentIndependent(const yoga::Node& node) { + const auto& style = node.style(); + const auto height = style.dimension(Dimension::Height); + const auto flexBasis = style.flexBasis(); + const bool hasRelativePercentPosition = + style.position(Edge::Top).isPercent() || + style.position(Edge::Bottom).isPercent() || + style.position(Edge::Vertical).isPercent() || + style.position(Edge::All).isPercent(); + return !node.hasMeasureFunc() && !node.hasMinContentMeasureFunc() && + !node.hasBaselineFunc() && !node.isReferenceBaseline() && + (height.isAuto() || height.isUndefined()) && + style.minDimension(Dimension::Height).isUndefined() && + style.maxDimension(Dimension::Height).isUndefined() && + (flexBasis.isAuto() || flexBasis.isUndefined()) && + !hasNonZeroFlex(node) && style.boxSizing() == BoxSizing::BorderBox && + !style.aspectRatio().isDefined() && + style.positionType() != PositionType::Absolute && + style.overflow() != Overflow::Scroll && + style.display() == Display::Flex && isColumn(style.flexDirection()) && + style.alignItems() == Align::Stretch && + (style.alignSelf() == Align::Auto || + style.alignSelf() == Align::Stretch) && + style.justifyContent() == Justify::FlexStart && + style.flexWrap() == Wrap::NoWrap && !style.gap(Gutter::All).isDefined() && + !style.gap(Gutter::Row).isDefined() && !hasRelativePercentPosition && + !hasNonZeroVerticalSpacing(style) && !hasPercentageLength(style); +} + +static bool canSkipHeightFitContent(const yoga::Node* const root) { + if (root == nullptr) { + return false; + } + + constexpr std::size_t maxPendingNodes = 64; + std::array stack{root}; + std::size_t stackSize = 1; + while (stackSize > 0) { + const auto node = stack[--stackSize]; + if (node == nullptr || !isHeightFitContentIndependent(*node)) { + return false; + } + for (const auto child : node->getLayoutChildren()) { + if (stackSize == stack.size()) { + return false; + } + stack[stackSize++] = child; + } + } + return true; +} + void constrainMaxSizeForMode( const yoga::Node* node, Direction direction, @@ -98,12 +266,8 @@ static void computeFlexBasisForChild( node->getConfig()->isExperimentalFeatureEnabled( ExperimentalFeature::FixFlexBasisFitContent); - bool useResolvedFlexBasis = + const bool useResolvedFlexBasis = resolvedFlexBasis.isDefined() && yoga::isDefined(mainAxisSize); - if (fixFlexBasisFitContent && resolvedFlexBasis.isDefined() && - resolvedFlexBasis.unwrap() > 0) { - useResolvedFlexBasis = true; - } if (useResolvedFlexBasis) { if (child->getLayout().computedFlexBasis.isUndefined() || @@ -175,32 +339,27 @@ static void computeFlexBasisForChild( } } - // For height in the main axis (column direction): when the - // FixFlexBasisFitContent feature is enabled, skip FitContent for - // non-measure container children inside scroll subtrees. This makes the - // flex basis independent of content-determined heights, preventing - // unnecessary re-measurement cascades when a sibling changes size in a - // ScrollView, while preserving viewport bounds for wrappers outside the - // scroll subtree. - // - // We only optimize the height (column) axis because text wrapping depends - // on width constraints propagating through container nodes. Removing - // FitContent from the width axis would cause text inside nested - // containers to stop wrapping. - bool applyHeightFitContent = - isMainAxisRow || node->style().overflow() != Overflow::Scroll; + // A zero-intrinsic-height column subtree has the same layout with an + // unbounded height, allowing its measurement cache to survive unrelated + // size changes elsewhere in a vertical scroll subtree. + const bool parentDoesNotScroll = + node != nullptr && node->style().overflow() != Overflow::Scroll; + bool applyHeightFitContent = isMainAxisRow || parentDoesNotScroll; if (fixFlexBasisFitContent) { - bool nodeHasScrollAncestor = false; - for (auto owner = node->getOwner(); owner != nullptr; - owner = owner->getOwner()) { - if (owner->style().overflow() == Overflow::Scroll) { - nodeHasScrollAncestor = true; - break; - } + const bool childHadOverflow = child != nullptr && child->isDirty() && + child->getLayout().hadOverflow(); + const bool hasHeightIndependentSubtree = node != nullptr && + child != nullptr && !isMainAxisRow && parentDoesNotScroll && + yoga::isUndefined(childHeight) && yoga::isDefined(height) && + isColumnStretchEdge(node, child) && + isInColumnStretchScrollSubtree(node) && + canSkipHeightFitContent(child); + if (hasHeightIndependentSubtree && childHadOverflow) { + child->setLayoutHadOverflow(false); + } + if (hasHeightIndependentSubtree) { + applyHeightFitContent = false; } - applyHeightFitContent = isMainAxisRow || - ((child->hasMeasureFunc() || !nodeHasScrollAncestor) && - node->style().overflow() != Overflow::Scroll); } if (applyHeightFitContent && yoga::isUndefined(childHeight) && yoga::isDefined(height)) { @@ -1565,6 +1724,12 @@ static void calculateLayoutImpl( // Set the resolved resolution in the node's layout. const Direction direction = node->resolveDirection(ownerDirection); node->setLayoutDirection(direction); + const bool fixFlexBasisFitContent = + node->getConfig()->isExperimentalFeatureEnabled( + ExperimentalFeature::FixFlexBasisFitContent); + if (fixFlexBasisFitContent && performLayout) { + node->setLayoutHadOverflow(false); + } const FlexDirection flexRowDirection = resolveDirection(FlexDirection::Row, direction); @@ -1680,9 +1845,9 @@ static void calculateLayoutImpl( // At this point we know we're going to perform work. Ensure that each child // has a mutable copy. node->cloneChildrenIfNeeded(); - // Reset layout flags, as they could have changed. - node->setLayoutHadOverflow(false); - + if (!fixFlexBasisFitContent || !performLayout) { + node->setLayoutHadOverflow(false); + } // Clean and update all display: contents nodes with a direct path to the // current node as they will not be traversed cleanupContentsNodesRecursively(node, performLayout); @@ -1741,53 +1906,14 @@ static void calculateLayoutImpl( // STEP 3: DETERMINE FLEX BASIS FOR EACH ITEM - // When this node is measured with MaxContent (FixFlexBasisFitContent - // behavior), availableInnerHeight is NaN. - // To preserve percentage resolution for descendants, derive a definite - // owner-size from the parent-provided ownerHeight. - float ownerWidthForChildren = availableInnerWidth; - float ownerHeightForChildren = availableInnerHeight; - - if (node->getConfig()->isExperimentalFeatureEnabled( - ExperimentalFeature::FixFlexBasisFitContent)) { - const auto* owner = node->getOwner(); - const bool isChildOfScrollContainer = - owner != nullptr && owner->style().overflow() == Overflow::Scroll; - - if (!isChildOfScrollContainer) { - if (yoga::isUndefined(ownerWidthForChildren) && - yoga::isDefined(ownerWidth)) { - ownerWidthForChildren = calculateAvailableInnerDimension( - node, - direction, - Dimension::Width, - ownerWidth - marginAxisRow, - paddingAndBorderAxisRow, - ownerWidth, - ownerWidth); - } - if (yoga::isUndefined(ownerHeightForChildren) && - yoga::isDefined(ownerHeight)) { - ownerHeightForChildren = calculateAvailableInnerDimension( - node, - direction, - Dimension::Height, - ownerHeight - marginAxisColumn, - paddingAndBorderAxisColumn, - ownerHeight, - ownerWidth); - } - } - } - // Computed basis + margins + gap float totalMainDim = 0; totalMainDim += computeFlexBasisForChildren( node, availableInnerWidth, availableInnerHeight, - ownerWidthForChildren, - ownerHeightForChildren, + availableInnerWidth, + availableInnerHeight, widthSizingMode, heightSizingMode, direction, @@ -2724,7 +2850,8 @@ void calculateLayout( // Increment the generation count. This will force the recursive routine to // visit all dirty nodes at least once. Subsequent visits will be skipped if // the input parameters don't change. - gCurrentGenerationCount.fetch_add(1, std::memory_order_relaxed); + const uint32_t currentGenerationCount = + gCurrentGenerationCount.fetch_add(1, std::memory_order_relaxed) + 1; node->processDimensions(); const Direction direction = node->resolveDirection(ownerDirection); float width = YGUndefined; @@ -2781,6 +2908,11 @@ void calculateLayout( heightSizingMode = yoga::isUndefined(height) ? SizingMode::MaxContent : SizingMode::StretchFit; } + const uint32_t generationCount = + node->getConfig()->isExperimentalFeatureEnabled( + ExperimentalFeature::FixFlexBasisFitContent) + ? currentGenerationCount + : gCurrentGenerationCount.load(std::memory_order_relaxed); if (calculateLayoutInternal( node, width, @@ -2794,7 +2926,7 @@ void calculateLayout( LayoutPassReason::kInitial, markerData, 0, // tree root - gCurrentGenerationCount.load(std::memory_order_relaxed))) { + generationCount)) { node->setPosition(node->getLayout().direction(), ownerWidth, ownerHeight); roundLayoutResultsToPixelGrid(node, 0.0f, 0.0f); }