Skip to content

Stale computedFlexBasis is reused across layouts: a flex: 1 node keeps its previous size after relayout at a new root size #2019

Description

@mozzius

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)

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions