Description
On iOS with the New Architecture, a flex: 1 Text keeps the height it was measured at in landscape after the device is rotated back to portrait. The text needs 7 lines in portrait but is laid out at the 4-line landscape height, so its tail is clipped and anything below it bunches up.
It only happens for a specific but very ordinary shape (see the reproducer):
<ScrollView>
<View style={{padding: 16}}> {/* cell, height auto */}
<View style={{flex: 1}}> {/* remove this flex: 1 and the bug is gone */}
<View style={{height: 24}} /> {/* fixed-height sibling, required */}
<View> {/* plain wrapper View, required */}
<View> {/* second plain wrapper View, required */}
<Text style={{flex: 1}}>{sevenLinesInPortrait}</Text>
</View>
</View>
</View>
</View>
</ScrollView>
Every ingredient marked "required" was confirmed by removing it and re-testing, both in the app and in a pure-Yoga harness: with any one of them gone the layout is correct. Two plain wrapper levels are the minimum; one does not reproduce, three does. No third-party libraries are involved (no navigation, no react-native-screens, no Reanimated, plain ScrollView). The enclosing cell keeps its correct portrait height, so the stale value is inside the flex chain, not in the cell.
This was found in the Bluesky app (bluesky-social/social-app#11198), where a post body in the threaded reply view sits in exactly this chain. It looks closely related to the long-standing #23443 (an ancestor View not recalculating after a descendant Text re-wraps on rotation, where "make the parent flex: 0" is a known workaround), but this report has a minimal reproducer and a root-cause analysis, so I opened it separately.
The Yoga-side report, with the C++ harness inlined, is react/yoga#2019.
Root cause (Yoga)
This is a Yoga bug, not a Fabric or iOS one. It reproduces in a standalone C++ program that builds an 8-node tree and calls YGNodeCalculateLayout three times at 400x800, 800x400, 400x800, using the Yoga sources shipped in react-native@0.87.1 and also facebook/yoga main (48182a3, 2026-09-01). The harness is in the reproducer under yoga-harness/ (./run.sh):
scrollView=1 wrappers=2 portrait#1=100.00 landscape=60.00 portrait#2=60.00 *** FAIL (stale) ***
scrollView=1 wrappers=1 portrait#1=100.00 landscape=60.00 portrait#2=100.00 PASS
The mechanism, with line numbers from ReactCommon/yoga/yoga/ in 0.87.1 (identical on Yoga main at algorithm/CalculateLayout.cpp:272-281):
-
layout.computedFlexBasis is a cross-layout cache that is never validated. It persists on a node across YGNodeCalculateLayout calls and is cleared in exactly one place, Node::markDirtyAndPropagate (node/Node.cpp:453-461), which clears the node it is called on and then walks up to the owners. It is never cleared downward. On rotation React Native only touches the root (YogaLayoutableShadowNode::layoutTree sets the root's min/max dimensions and calls YGNodeCalculateLayout), so every descendant enters the portrait layout still holding the flex basis it computed during the landscape layout.
-
The "resolved flex basis" branch deliberately keeps whatever is already there. For a child with flexBasis: 0 (what flex: 1 resolves to) inside a container whose main-axis size is definite, computeFlexBasisForChild only writes the resolved basis when nothing is stored yet (algorithm/CalculateLayout.cpp:108-117):
if (useResolvedFlexBasis) {
if (child->getLayout().computedFlexBasis.isUndefined() ||
(child->getConfig()->isExperimentalFeatureEnabled(ExperimentalFeature::WebFlexBasis) &&
child->getLayout().computedFlexBasisGeneration != generationCount)) {
child->setLayoutComputedFlexBasis(yoga::maxOrDefined(resolvedFlexBasis, paddingAndBorder));
}
}
Within a single layout this retention is load-bearing: the max-content pass (parent height undefined) measures the child's content and stores it here (:294-297), and the final 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" in React Native. The guard checks whether a value exists, not which layout produced it.
-
The pass that would refresh the value can be skipped. In the second portrait layout the ScrollView sizes its content at height undefined (max-content). The descent reaches the flex: 1 column A at (370 x undefined, MaxContent), the exact constraints it was measured at in the first portrait layout. A was never dirtied, so its measurement cache still holds that entry, and calculateLayoutInternal returns it without visiting the subtree (:2629-2637). The Text and its wrappers are never re-measured, so the Text's computedFlexBasis still holds the landscape value (4 lines, 83.2pt).
-
A deeper node then misses its cache and consumes the stale basis. In the final performLayout pass, A (169.6) -> the first wrapper (145.6, correct, from its own cache) -> the second wrapper W at (370 x 145.6, StretchFit/FitContent). W misses its cache and is laid out for real. Its child T has flexBasis: 0 and a definite parent height, so the branch in (2) runs, sees a defined computedFlexBasis, and keeps 83.2. W is not flexible, so W's own basis becomes its content size, 83.2, and T is stretched to 83.2 inside a 145.6pt parent. Everything above W is correct; W and T are landscape values. That matches the device: the cell keeps its portrait height, the text inside is clamped to 4 lines.
Why W misses where the wrapper above it hits is a second, minor quirk: W had accumulated 8 measurement entries (4 portrait + 4 landscape). When nextCachedMeasurementsIndex wraps to 0 on a performLayout call (:2660-2672), the new entry goes to cachedLayout and the index is not advanced, so the stored measurements become unreachable (the lookup loop runs to nextCachedMeasurementsIndex == 0). This is not the root cause, it only decides which node in the chain becomes the victim, but it is why the bug needs two plain wrapper levels between the flex: 1 column and the Text: with one, that node's cache still hits and the flex: 1 Text is stretched to a correct height, masking the stale basis. The flexDirection: 'row' with a flex: 1 child in the original app was incidental; two plain Views do the same thing.
Things I checked that are not involved: the lenient measure-function cache heuristics in algorithm/Cache.cpp (all of T's own cache hits are consistent with the constraints it was given); Fabric's layout propagation (YogaLayoutableShadowNode::layout, which runs downstream of an already-wrong Yoga result); Fabric's clone-and-dirty on re-render (the harness reproduces with no YGNodeMarkDirty at all); YGErrata (all four settings behave the same).
Remedies tried in the harness:
| change |
0.87.1 Yoga |
Yoga main |
YGNodeMarkDirty on the Text before the second portrait layout |
fixes |
fixes |
Remove flex: 1 from the outer column (the app-level workaround) |
fixes |
fixes |
YGExperimentalFeatureFixFlexBasisFitContent (fixYogaFlexBasisFitContentInMainAxis) |
fixes |
does not fix (that path was rewritten in react/yoga#1997) |
YGExperimentalFeatureWebFlexBasis |
worse, Text becomes 0pt |
worse, 0pt |
Clearing computedFlexBasis on clean nodes before layout |
worse, 0pt (the resolved branch writes 0 and no max-content measurement happens) |
worse, 0pt |
Clearing only A's measurement cache |
no effect, the next node down hits its own max-content entry and skips the subtree instead |
no effect |
So a fix cannot be "reset computedFlexBasis when the node is clean". The retained value needs to be treated as stale when computedFlexBasisGeneration != generationCount, and a stale value should trigger a fresh content measurement of the child (the same measurement the max-content pass would have made) rather than a write of the resolved 0. computedFlexBasisGeneration already exists for exactly this comparison but is only consulted under WebFlexBasis.
Android is untested here but, since the harness is pure Yoga, it should be affected as well.
Steps to reproduce
- Clone https://github.com/mozzius/rotation-text-repro,
cd ReproducerApp, yarn install, cd ios && bundle install && bundle exec pod install, yarn start, yarn ios.
- In portrait, note that every row's paragraph wraps onto 7 lines ending with
[end #N], and the grey readout in each row's corner says h=145.7.
- Rotate the simulator to landscape (Cmd + Left). The paragraphs re-wrap onto 4 lines.
- Rotate back to portrait (Cmd + Right).
Expected: every row is identical to step 2.
Actual: every paragraph is cut off after 4 lines, [end #N] is gone, and the readout turns red: h=83.3 (was 145.7). The console logs [REPRO] row N portrait height changed 145.7 -> 83.3.
The readout compares each Text's onLayout height against the first height it was given in portrait, so a red label is the bug without having to eyeball the screenshot. The flex:1 OFF button removes the flex: 1 from the outer column; with it off the same rotation produces no red labels.
React Native Version
0.87.1
Affected Platforms
Runtime - iOS (Android untested; the pure-Yoga harness suggests it is affected too)
Output of npx @react-native-community/cli info
System:
OS: macOS 26.5.2
CPU: (14) arm64 Apple M4 Pro
Memory: 134.48 MB / 48.00 GB
Shell:
version: 5.3.15
path: /opt/homebrew/bin/bash
Binaries:
Node:
version: 24.19.0
path: ~/.nvm/versions/node/v24.19.0/bin/node
Yarn:
version: 1.22.22
path: ~/.nvm/versions/node/v24.19.0/bin/yarn
npm:
version: 11.17.0
path: ~/.nvm/versions/node/v24.19.0/bin/npm
Watchman:
version: 2026.07.27.00
path: /opt/homebrew/bin/watchman
Managers:
CocoaPods:
version: 1.17.0
path: ~/.rbenv/shims/pod
SDKs:
iOS SDK:
Platforms:
- DriverKit 25.5
- iOS 26.5
- macOS 26.5
- tvOS 26.5
- visionOS 26.5
- watchOS 26.5
Android SDK: Not Found
IDEs:
Android Studio: 2025.3 AI-253.32098.37.2534.15232325
Xcode:
version: 26.6/17F113
path: /usr/bin/xcodebuild
Languages:
Java:
version: 17.0.20.1
path: /usr/bin/javac
Ruby:
version: 2.7.6
path: ~/.rbenv/shims/ruby
npmPackages:
"@react-native-community/cli":
installed: 20.2.0
wanted: 20.2.0
react:
installed: 19.2.3
wanted: 19.2.3
react-native:
installed: 0.87.1
wanted: 0.87.1
react-native-macos: Not Found
npmGlobalPackages:
"*react-native*": Not Found
Android:
hermesEnabled: true
newArchEnabled: true
iOS:
hermesEnabled: true
newArchEnabled: true
Stacktrace or Logs
[REPRO] row 0 portrait height changed 145.7 -> 83.3
[REPRO] row 1 portrait height changed 145.7 -> 83.3
[REPRO] row 2 portrait height changed 145.7 -> 83.3
...
[REPRO] flexOne -> false <- no further height-change warnings after this
MANDATORY Reproducer
https://github.com/mozzius/rotation-text-repro
Screenshots and Videos
Portrait before the rotation, portrait after the round trip (bug), and portrait after the round trip with the outer flex: 1 removed (control):

Description
On iOS with the New Architecture, a
flex: 1Textkeeps the height it was measured at in landscape after the device is rotated back to portrait. The text needs 7 lines in portrait but is laid out at the 4-line landscape height, so its tail is clipped and anything below it bunches up.It only happens for a specific but very ordinary shape (see the reproducer):
Every ingredient marked "required" was confirmed by removing it and re-testing, both in the app and in a pure-Yoga harness: with any one of them gone the layout is correct. Two plain wrapper levels are the minimum; one does not reproduce, three does. No third-party libraries are involved (no navigation, no
react-native-screens, no Reanimated, plainScrollView). The enclosing cell keeps its correct portrait height, so the stale value is inside the flex chain, not in the cell.This was found in the Bluesky app (bluesky-social/social-app#11198), where a post body in the threaded reply view sits in exactly this chain. It looks closely related to the long-standing #23443 (an ancestor
Viewnot recalculating after a descendantTextre-wraps on rotation, where "make the parentflex: 0" is a known workaround), but this report has a minimal reproducer and a root-cause analysis, so I opened it separately.The Yoga-side report, with the C++ harness inlined, is react/yoga#2019.
Root cause (Yoga)
This is a Yoga bug, not a Fabric or iOS one. It reproduces in a standalone C++ program that builds an 8-node tree and calls
YGNodeCalculateLayoutthree times at400x800,800x400,400x800, using the Yoga sources shipped inreact-native@0.87.1and alsofacebook/yogamain (48182a3, 2026-09-01). The harness is in the reproducer underyoga-harness/(./run.sh):The mechanism, with line numbers from
ReactCommon/yoga/yoga/in 0.87.1 (identical on Yoga main atalgorithm/CalculateLayout.cpp:272-281):layout.computedFlexBasisis a cross-layout cache that is never validated. It persists on a node acrossYGNodeCalculateLayoutcalls and is cleared in exactly one place,Node::markDirtyAndPropagate(node/Node.cpp:453-461), which clears the node it is called on and then walks up to the owners. It is never cleared downward. On rotation React Native only touches the root (YogaLayoutableShadowNode::layoutTreesets the root's min/max dimensions and callsYGNodeCalculateLayout), so every descendant enters the portrait layout still holding the flex basis it computed during the landscape layout.The "resolved flex basis" branch deliberately keeps whatever is already there. For a child with
flexBasis: 0(whatflex: 1resolves to) inside a container whose main-axis size is definite,computeFlexBasisForChildonly writes the resolved basis when nothing is stored yet (algorithm/CalculateLayout.cpp:108-117):Within a single layout this retention is load-bearing: the max-content pass (parent height undefined) measures the child's content and stores it here (
:294-297), and the final definite-size pass keeps that value instead of overwriting it with 0. That is what makesflex: 1inside an auto-height column behave as "content height" in React Native. The guard checks whether a value exists, not which layout produced it.The pass that would refresh the value can be skipped. In the second portrait layout the ScrollView sizes its content at height
undefined(max-content). The descent reaches theflex: 1columnAat(370 x undefined, MaxContent), the exact constraints it was measured at in the first portrait layout.Awas never dirtied, so its measurement cache still holds that entry, andcalculateLayoutInternalreturns it without visiting the subtree (:2629-2637). TheTextand its wrappers are never re-measured, so theText'scomputedFlexBasisstill holds the landscape value (4 lines, 83.2pt).A deeper node then misses its cache and consumes the stale basis. In the final
performLayoutpass,A(169.6) -> the first wrapper (145.6, correct, from its own cache) -> the second wrapperWat(370 x 145.6, StretchFit/FitContent).Wmisses its cache and is laid out for real. Its childThasflexBasis: 0and a definite parent height, so the branch in (2) runs, sees a definedcomputedFlexBasis, and keeps 83.2.Wis not flexible, soW's own basis becomes its content size, 83.2, andTis stretched to 83.2 inside a 145.6pt parent. Everything aboveWis correct;WandTare landscape values. That matches the device: the cell keeps its portrait height, the text inside is clamped to 4 lines.Why
Wmisses where the wrapper above it hits is a second, minor quirk:Whad accumulated 8 measurement entries (4 portrait + 4 landscape). WhennextCachedMeasurementsIndexwraps to 0 on aperformLayoutcall (:2660-2672), the new entry goes tocachedLayoutand the index is not advanced, so the stored measurements become unreachable (the lookup loop runs tonextCachedMeasurementsIndex == 0). This is not the root cause, it only decides which node in the chain becomes the victim, but it is why the bug needs two plain wrapper levels between theflex: 1column and theText: with one, that node's cache still hits and theflex: 1Textis stretched to a correct height, masking the stale basis. TheflexDirection: 'row'with aflex: 1child in the original app was incidental; two plainViews do the same thing.Things I checked that are not involved: the lenient measure-function cache heuristics in
algorithm/Cache.cpp(all ofT's own cache hits are consistent with the constraints it was given); Fabric's layout propagation (YogaLayoutableShadowNode::layout, which runs downstream of an already-wrong Yoga result); Fabric's clone-and-dirty on re-render (the harness reproduces with noYGNodeMarkDirtyat all);YGErrata(all four settings behave the same).Remedies tried in the harness:
YGNodeMarkDirtyon theTextbefore the second portrait layoutflex: 1from the outer column (the app-level workaround)YGExperimentalFeatureFixFlexBasisFitContent(fixYogaFlexBasisFitContentInMainAxis)YGExperimentalFeatureWebFlexBasisTextbecomes 0ptcomputedFlexBasison clean nodes before layoutA's measurement cacheSo a fix cannot be "reset
computedFlexBasiswhen the node is clean". The retained value needs to be treated as stale whencomputedFlexBasisGeneration != generationCount, and a stale value should trigger a fresh content measurement of the child (the same measurement the max-content pass would have made) rather than a write of the resolved0.computedFlexBasisGenerationalready exists for exactly this comparison but is only consulted underWebFlexBasis.Android is untested here but, since the harness is pure Yoga, it should be affected as well.
Steps to reproduce
cd ReproducerApp,yarn install,cd ios && bundle install && bundle exec pod install,yarn start,yarn ios.[end #N], and the grey readout in each row's corner saysh=145.7.Expected: every row is identical to step 2.
Actual: every paragraph is cut off after 4 lines,
[end #N]is gone, and the readout turns red:h=83.3 (was 145.7). The console logs[REPRO] row N portrait height changed 145.7 -> 83.3.The readout compares each
Text'sonLayoutheight against the first height it was given in portrait, so a red label is the bug without having to eyeball the screenshot. Theflex:1 OFFbutton removes theflex: 1from the outer column; with it off the same rotation produces no red labels.React Native Version
0.87.1
Affected Platforms
Runtime - iOS (Android untested; the pure-Yoga harness suggests it is affected too)
Output of
npx @react-native-community/cli infoStacktrace or Logs
MANDATORY Reproducer
https://github.com/mozzius/rotation-text-repro
Screenshots and Videos
Portrait before the rotation, portrait after the round trip (bug), and portrait after the round trip with the outer
flex: 1removed (control):