Repository navigation
[iOS][Fabric] Words jump between lines while a <Text> grows with LayoutAnimation #59161
Description
Description
When a <Text> gets taller during a LayoutAnimation (for example a "read more" toggle from numberOfLines={2} to no limit), I'd expect the text to unroll: the final layout is revealed line by line, and every word stays on the line it ends up on.
On iOS with the New Architecture, that's not what happens. On every animation frame, the last visible line holds the words that belong on the next line, clipped at the right edge. When the frame becomes tall enough for the next line, those words jump down to it. Lines also appear only once they fit completely, with an empty gap below the last visible line before that.
Two consecutive frames of a 30 fps screen recording (33 ms apart):
Left: line 6 is word25 word26 word27 word28 word29…, clipped at the edge.
Right: word29 has moved to line 7. In the final layout, line 6 is word25 word26 word27 word28.
Comparison:
| Configuration | Result |
|---|---|
| iOS, New Architecture, 0.88.0 | Broken |
iOS, New Architecture, main @ 96dddb0feb0 |
Broken |
| iOS, old architecture, 0.81.6 | Correct |
| Android, old architecture, 0.81.6 | Correct |
Android, New Architecture, 0.88.0, enableLayoutAnimationsOnAndroid on |
Correct |
"Correct" means the text unrolls with the line breaks of the final layout, and partially revealed lines are clipped mid-line. On Android with the New Architecture, LayoutAnimation is off by default (#52921), so I turned the feature flag on to compare. This looks like an iOS-only New Architecture regression.
Likely cause
RCTParagraphComponentView calls setNeedsDisplay for every interpolated LayoutMetrics frame. drawRect: then lays out the attributed string again in an NSTextContainer sized to the current, animated bounds, not the final layout size. When numberOfLines is 0, RCTTextLayoutManager sets:
textContainer.lineBreakMode = paragraphAttributes.maximumNumberOfLines > 0
? RCTNSLineBreakModeFromEllipsizeMode(paragraphAttributes.ellipsizeMode)
: NSLineBreakByClipping;NSTextContainer.lineBreakMode applies to the last line that fits in the container. With NSLineBreakByClipping, that line isn't word-wrapped: it takes the rest of the text and clips it at the right edge. Because the container is shorter than the final text during the animation, a different line counts as the last one in each frame.
As an experiment, I changed NSLineBreakByClipping to NSLineBreakByWordWrapping. The jumping stopped, but lines still appeared only once they fit completely. A complete fix probably needs to draw the text at its final laid-out size and let the view clip it, which is what the old architecture and Android do.
Steps to reproduce
- Clone the reproducer and run it on iOS:
cd ReproducerApp && yarn install && (cd ios && bundle install && bundle exec pod install) && yarn ios - Tap Expand.
- Watch the last visible line during the 3-second animation: words appear at the end of the line, clipped at the edge, then jump to the next line once it is revealed.
The whole repro is this component:
import React, { useState } from 'react';
import {
LayoutAnimation,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
const TEXT = Array.from({ length: 40 }, (_, i) => `word${i + 1}`).join(' ');
export default function App() {
const [expanded, setExpanded] = useState(false);
const toggle = () => {
// Slow, linear animation so that every frame is easy to follow.
LayoutAnimation.configureNext({
duration: 3000,
update: { type: LayoutAnimation.Types.linear },
});
setExpanded(value => !value);
};
return (
<View style={styles.container}>
<Pressable onPress={toggle} style={styles.button}>
<Text style={styles.buttonText}>
{expanded ? 'Collapse' : 'Expand'}
</Text>
</Pressable>
<Text style={styles.text} numberOfLines={expanded ? undefined : 2}>
{TEXT}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, paddingTop: 100, paddingHorizontal: 20 },
button: { padding: 12, marginBottom: 12, alignItems: 'center', backgroundColor: '#ddd' },
buttonText: { fontSize: 18 },
text: { fontSize: 22, backgroundColor: 'gold' },
});The repro uses Pressable rather than Button on purpose. With Button on 0.88.0, the iOS animation stopped after about 0.3 s and jumped to the final layout, which didn't happen on main. I haven't investigated that; it's separate from this issue.
React Native Version
0.88.0
Affected Platforms
Runtime - iOS
Areas
Fabric - The New Renderer
Output of npx @react-native-community/cli info
System:
OS: macOS 27.0.1
CPU: (15) arm64 Apple M5 Pro
Memory: 63.78 MB / 48.00 GB
Shell:
version: "5.9"
path: /bin/zsh
Binaries:
Node:
version: 24.19.0
path: /Users/tomekzaw/.nvm/versions/node/v24.19.0/bin/node
Yarn:
version: 1.22.22
path: /opt/homebrew/bin/yarn
npm:
version: 11.17.0
path: /Users/tomekzaw/.nvm/versions/node/v24.19.0/bin/npm
Watchman:
version: 2026.10.12.00
path: /opt/homebrew/bin/watchman
Managers:
CocoaPods:
version: 1.17.0
path: /opt/homebrew/bin/pod
SDKs:
iOS SDK:
Platforms:
- DriverKit 27.0
- iOS 27.1
- macOS 27.0
- tvOS 27.0
- visionOS 27.0
- watchOS 27.0
Android SDK:
API Levels:
- "36"
- "36"
- "37"
Build Tools:
- 35.0.0
- 36.0.0
- 36.1.0
- 37.0.0
System Images:
- android-37.0 | 16 KB Page Size Google Play ARM 64 v8a
Android NDK: Not Found
IDEs:
Android Studio: 2026.1 AI-261.26222.65.2614.16204760
Xcode:
version: 27.1/27A9275
path: /usr/bin/xcodebuild
Languages:
Java:
version: 17.0.20.1
path: /usr/bin/javac
Ruby:
version: 2.7.6
path: /Users/tomekzaw/.rbenv/shims/ruby
npmPackages:
"@react-native-community/cli":
installed: 20.2.0
wanted: 20.2.0
react:
installed: 19.3.0
wanted: 19.3.0
react-native:
installed: 0.88.0
wanted: 0.88.0
react-native-macos: Not Found
npmGlobalPackages:
"*react-native*": Not Found
Android:
hermesEnabled: true
newArchEnabled: true
iOS:
hermesEnabled: true
newArchEnabled: true
Stacktrace or Logs
N/A, visual issue only (no errors or warnings are logged).
MANDATORY Reproducer
https://github.com/tomekzaw/repro-layout-animations-ios
Screenshots and Videos
Tested on the iPhone Air simulator (iOS 27.0). The old-architecture comparison ran on the iPhone 17 Pro simulator (iOS 26.5), because the 0.81 template doesn't adopt the UIScene lifecycle that iOS 27 requires.
Expected behavior, shown by the same repro on Android with enableLayoutAnimationsOnAndroid on: partially revealed lines are clipped mid-line, and line breaks never change.
Expand on iOS, 0.88.0, New Architecture (broken):
Activity