Skip to content

[iOS][Fabric] Words jump between lines while a <Text> grows with LayoutAnimation #59161

Open

Description

@tomekzaw

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):

Image

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

  1. Clone the reproducer and run it on iOS: cd ReproducerApp && yarn install && (cd ios && bundle install && bundle exec pod install) && yarn ios
  2. Tap Expand.
  3. 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.

Image

Expand on iOS, 0.88.0, New Architecture (broken):

ios-0.88-expand-bug.mp4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions