Summary
A node's layout.computedFlexBasis survives from one YGNodeCalculateLayout call to the next and is only ever cleared by markDirtyAndPropagate on that exact node. When a later layout at a different root size happens to hit an ancestor's measurement cache during the max-content pass, the subtree is skipped, the child's computedFlexBasis is never refreshed, and the definite-size pass then consumes the previous layout's value through the retention guard in computeFlexBasisForChild. The visible result in React Native is a flex: 1 Text that keeps its landscape height after rotating back to portrait, clipped inside a correctly sized parent.
Reproduces on main (48182a3, 2026-09-01) and on the Yoga shipped in react-native 0.87.1. React Native side: react/react-native#58294 (app reproducer: https://github.com/mozzius/rotation-text-repro, harness under yoga-harness/). Very likely the same bug as react/react-native#23443 (2019).
Minimal reproduction
Eight nodes, three layouts, no measure-function trickery beyond "taller when narrower":
root (exact 400 x 800, then 800 x 400, then 400 x 800 again)
SV overflow: scroll, flexGrow 1 <- supplies the max-content pass
CC plain column
RC plain column <- height auto
A flex: 1 <- required
S height: 24 <- required
W1 plain column <- required
W2 plain column <- required (one wrapper level passes)
T flex: 1, measure func
/*
* Minimal, self-contained reproduction of the stale-flex-basis bug, in the
* shape a Yoga C++ regression test would take.
*
* root (exact 400 x 800, then 800 x 400, then 400 x 800 again)
* SV overflow: scroll, flexGrow 1 [optional - see kUseScrollView]
* CC plain column
* RC plain column <- height auto
* A flex: 1 <- REQUIRED
* S height: 24 <- REQUIRED (fixed sibling)
* W1 plain column <- REQUIRED (wrapper level 1)
* W2 plain column <- REQUIRED (wrapper level 2)
* T flex: 1, measure func <- text
*
* T's measure func behaves like text: taller when narrower.
* Expected: T's height after rotating away and back == its first height.
*/
#include <yoga/Yoga.h>
#include <cmath>
#include <cstdio>
#include <string>
#include <limits>
static bool kUseScrollView = true;
static int kWrapperLevels = 2;
/* 2000pt of text laid out at the available width, 20pt line height. */
static YGSize measure(YGNodeConstRef, float w, YGMeasureMode wm, float h, YGMeasureMode hm) {
float maxW = (wm == YGMeasureModeUndefined) ? std::numeric_limits<float>::infinity() : w;
float lines = std::isfinite(maxW) && maxW > 0 ? std::ceil(2000.0f / maxW) : 1.0f;
float natH = lines * 20.0f;
YGSize out{std::isfinite(maxW) ? maxW : 2000.0f, natH};
if (hm == YGMeasureModeExactly) out.height = h;
else if (hm == YGMeasureModeAtMost && natH > h) out.height = h;
if (wm == YGMeasureModeExactly) out.width = w;
return out;
}
static YGNodeRef gT = nullptr;
static YGNodeRef build(YGConfigRef cfg) {
YGNodeRef root = YGNodeNewWithConfig(cfg);
YGNodeRef parent = root;
if (kUseScrollView) {
YGNodeRef sv = YGNodeNewWithConfig(cfg);
YGNodeStyleSetFlexGrow(sv, 1);
YGNodeStyleSetFlexShrink(sv, 1);
YGNodeStyleSetOverflow(sv, YGOverflowScroll);
YGNodeInsertChild(root, sv, 0);
YGNodeRef cc = YGNodeNewWithConfig(cfg);
YGNodeInsertChild(sv, cc, 0);
parent = cc;
}
YGNodeRef rc = YGNodeNewWithConfig(cfg);
YGNodeInsertChild(parent, rc, 0);
YGNodeRef a = YGNodeNewWithConfig(cfg);
YGNodeStyleSetFlex(a, 1);
YGNodeInsertChild(rc, a, 0);
YGNodeRef s = YGNodeNewWithConfig(cfg);
YGNodeStyleSetHeight(s, 24);
YGNodeInsertChild(a, s, 0);
YGNodeRef p = a;
for (int i = 0; i < kWrapperLevels; i++) {
YGNodeRef m = YGNodeNewWithConfig(cfg);
YGNodeInsertChild(p, m, YGNodeGetChildCount(p));
p = m;
}
gT = YGNodeNewWithConfig(cfg);
YGNodeStyleSetFlex(gT, 1);
YGNodeSetMeasureFunc(gT, measure);
YGNodeInsertChild(p, gT, 0);
return root;
}
static void layoutAt(YGNodeRef root, float w, float h) {
YGNodeStyleSetMinWidth(root, w); YGNodeStyleSetMaxWidth(root, w);
YGNodeStyleSetMinHeight(root, h); YGNodeStyleSetMaxHeight(root, h);
YGNodeCalculateLayout(root, w, h, YGDirectionLTR);
}
int main(int argc, char** argv) {
for (int i = 1; i < argc; i++) {
if (std::string(argv[i]) == "--no-scrollview") kUseScrollView = false;
if (std::string(argv[i]) == "--wrappers=1") kWrapperLevels = 1;
if (std::string(argv[i]) == "--wrappers=3") kWrapperLevels = 3;
}
YGConfigRef cfg = YGConfigNew();
YGConfigSetPointScaleFactor(cfg, 3.0f);
YGConfigSetErrata(cfg, YGErrataAll);
YGNodeRef root = build(cfg);
layoutAt(root, 400, 800);
float h1 = YGNodeLayoutGetHeight(gT);
layoutAt(root, 800, 400);
float hl = YGNodeLayoutGetHeight(gT);
layoutAt(root, 400, 800);
float h2 = YGNodeLayoutGetHeight(gT);
printf("scrollView=%d wrappers=%d portrait#1=%.2f landscape=%.2f portrait#2=%.2f %s\n",
(int)kUseScrollView, kWrapperLevels, h1, hl, h2,
std::fabs(h1 - h2) < 0.5f ? "PASS" : "*** FAIL (stale) ***");
return std::fabs(h1 - h2) < 0.5f ? 0 : 1;
}
$ clang++ -std=c++20 -I. $(find ./yoga -name '*.cpp') minimal.cpp -o minimal
$ ./minimal
scrollView=1 wrappers=2 portrait#1=100.00 landscape=60.00 portrait#2=60.00 *** FAIL (stale) ***
$ ./minimal --wrappers=1
scrollView=1 wrappers=1 portrait#1=100.00 landscape=60.00 portrait#2=100.00 PASS
T should come back to 100 on the third layout. It comes back at 60, the value from the 800-wide layout.
Mechanism (line numbers from main at 48182a3)
-
layout.computedFlexBasis is per-node state that persists across YGNodeCalculateLayout calls. It is cleared in exactly one place, Node::markDirtyAndPropagate (yoga/node/Node.cpp:453), which clears the node it is called on and then walks up through owner_. Nothing ever clears it downward. A caller that resizes the root (React Native on rotation sets the root's min/max dimensions) dirties only the root, so every descendant enters the new layout still holding the basis it computed in the previous one.
-
computeFlexBasisForChild keeps whatever is already stored. For a child with a definite resolved basis (flex: 1 resolves to flexBasis: 0) in a container with a definite main-axis size, the resolved branch only writes when nothing is there yet (yoga/algorithm/CalculateLayout.cpp:272-281):
if (useResolvedFlexBasis) {
if (child->getLayout().computedFlexBasis.isUndefined() ||
(child->getConfig()->isExperimentalFeatureEnabled(ExperimentalFeature::WebFlexBasis) &&
child->getLayout().computedFlexBasisGeneration != generationCount)) {
const FloatOptional paddingAndBorder = FloatOptional(paddingAndBorderForAxis(child, mainAxis, direction, ownerWidth));
child->setLayoutComputedFlexBasis(yoga::maxOrDefined(resolvedFlexBasis, paddingAndBorder));
}
}
Within one layout this retention is load-bearing: the max-content pass (container main size undefined) takes the measure branch and stores the child's measured content size, and the later definite-size pass keeps that value instead of overwriting it with 0. That is what makes flex: 1 inside an auto-height column behave as "content height". The guard tests whether a value exists, not which layout produced it; computedFlexBasisGeneration is stamped on every branch (:458) but only consulted under WebFlexBasis.
-
The pass that would refresh the value can be skipped. In the third layout, SV sizes CC at height undefined. The descent reaches A at (width, undefined, MaxContent), the exact constraints it was measured at in the first layout. A was never dirtied, so its measurement cache still holds that entry and calculateLayoutInternal returns it without visiting the subtree (:2762). T is not re-measured; its computedFlexBasis still holds the 60 from the 800-wide layout.
-
A deeper node then misses its cache and consumes the stale basis. In the final performLayout pass A -> W1 (correct, from its own cache) -> W2, which misses and is laid out for real with a definite height from W1. It computes T's basis: resolved 0, definite main size, so the branch in (2) runs, finds 60 already stored, and keeps it. W2 is not flexible, so its own basis is its content size, 60, and T is stretched to 60 inside a 100-tall W1.
Why W2 misses where W1 hits is a second, minor quirk that only decides which node becomes the victim: W2 accumulates 8 measurement entries across the three layouts. When nextCachedMeasurementsIndex wraps to 0 on a performLayout call (:2795), the new entry goes to cachedLayout and the index is not advanced, so the 8 stored measurements become unreachable (the lookup loop runs to nextCachedMeasurementsIndex == 0). This is why the repro needs two plain wrapper levels: with one, that node's cache still hits, T is stretched to a correct height, and the stale basis is masked.
Things tried
| change |
Yoga in RN 0.87.1 |
main 48182a3 |
YGNodeMarkDirty(T) before the third layout |
fixes |
fixes |
no flex: 1 on A |
fixes |
fixes |
YGExperimentalFeatureFixFlexBasisFitContent |
fixes |
does not fix (path rewritten in #1997) |
YGExperimentalFeatureWebFlexBasis |
worse, T becomes 0 |
worse, 0 |
clearing computedFlexBasis on clean nodes before layout |
worse, 0 (resolved branch writes 0, no measurement follows) |
worse, 0 |
clearing only A's measurement cache |
no effect, the next node down hits its own max-content entry |
no effect |
every YGErrata value |
no effect |
no effect |
So the fix is not "reset computedFlexBasis when the node is clean". A value whose computedFlexBasisGeneration is not the current generation needs to be treated as stale, and a stale value should trigger a fresh content measurement of the child (what the skipped max-content pass would have done) rather than a write of the resolved 0.
A regression test would fit tests/YGRelayoutTest.cpp (layout, layout at a new root size, layout at the original size, assert the measured node's height is unchanged) or tests/YGFlexBasisFitContentTest.cpp.
Summary
A node's
layout.computedFlexBasissurvives from oneYGNodeCalculateLayoutcall to the next and is only ever cleared bymarkDirtyAndPropagateon that exact node. When a later layout at a different root size happens to hit an ancestor's measurement cache during the max-content pass, the subtree is skipped, the child'scomputedFlexBasisis never refreshed, and the definite-size pass then consumes the previous layout's value through the retention guard incomputeFlexBasisForChild. The visible result in React Native is aflex: 1Textthat keeps its landscape height after rotating back to portrait, clipped inside a correctly sized parent.Reproduces on main (
48182a3, 2026-09-01) and on the Yoga shipped in react-native 0.87.1. React Native side: react/react-native#58294 (app reproducer: https://github.com/mozzius/rotation-text-repro, harness underyoga-harness/). Very likely the same bug as react/react-native#23443 (2019).Minimal reproduction
Eight nodes, three layouts, no measure-function trickery beyond "taller when narrower":
Tshould come back to 100 on the third layout. It comes back at 60, the value from the 800-wide layout.Mechanism (line numbers from main at
48182a3)layout.computedFlexBasisis per-node state that persists acrossYGNodeCalculateLayoutcalls. It is cleared in exactly one place,Node::markDirtyAndPropagate(yoga/node/Node.cpp:453), which clears the node it is called on and then walks up throughowner_. Nothing ever clears it downward. A caller that resizes the root (React Native on rotation sets the root's min/max dimensions) dirties only the root, so every descendant enters the new layout still holding the basis it computed in the previous one.computeFlexBasisForChildkeeps whatever is already stored. For a child with a definite resolved basis (flex: 1resolves toflexBasis: 0) in a container with a definite main-axis size, the resolved branch only writes when nothing is there yet (yoga/algorithm/CalculateLayout.cpp:272-281):Within one layout this retention is load-bearing: the max-content pass (container main size undefined) takes the measure branch and stores the child's measured content size, and the later definite-size pass keeps that value instead of overwriting it with 0. That is what makes
flex: 1inside an auto-height column behave as "content height". The guard tests whether a value exists, not which layout produced it;computedFlexBasisGenerationis stamped on every branch (:458) but only consulted underWebFlexBasis.The pass that would refresh the value can be skipped. In the third layout,
SVsizesCCat height undefined. The descent reachesAat(width, undefined, MaxContent), the exact constraints it was measured at in the first layout.Awas never dirtied, so its measurement cache still holds that entry andcalculateLayoutInternalreturns it without visiting the subtree (:2762).Tis not re-measured; itscomputedFlexBasisstill holds the 60 from the 800-wide layout.A deeper node then misses its cache and consumes the stale basis. In the final
performLayoutpassA->W1(correct, from its own cache) ->W2, which misses and is laid out for real with a definite height fromW1. It computesT's basis: resolved0, definite main size, so the branch in (2) runs, finds 60 already stored, and keeps it.W2is not flexible, so its own basis is its content size, 60, andTis stretched to 60 inside a 100-tallW1.Why
W2misses whereW1hits is a second, minor quirk that only decides which node becomes the victim:W2accumulates 8 measurement entries across the three layouts. WhennextCachedMeasurementsIndexwraps to 0 on aperformLayoutcall (:2795), the new entry goes tocachedLayoutand the index is not advanced, so the 8 stored measurements become unreachable (the lookup loop runs tonextCachedMeasurementsIndex == 0). This is why the repro needs two plain wrapper levels: with one, that node's cache still hits,Tis stretched to a correct height, and the stale basis is masked.Things tried
48182a3YGNodeMarkDirty(T)before the third layoutflex: 1onAYGExperimentalFeatureFixFlexBasisFitContentYGExperimentalFeatureWebFlexBasisTbecomes 0computedFlexBasison clean nodes before layoutA's measurement cacheYGErratavalueSo the fix is not "reset
computedFlexBasiswhen the node is clean". A value whosecomputedFlexBasisGenerationis not the current generation needs to be treated as stale, and a stale value should trigger a fresh content measurement of the child (what the skipped max-content pass would have done) rather than a write of the resolved0.A regression test would fit
tests/YGRelayoutTest.cpp(layout, layout at a new root size, layout at the original size, assert the measured node's height is unchanged) ortests/YGFlexBasisFitContentTest.cpp.