Skip to content

[iOS][Fabric] Runtime Dynamic Type change leaves stale text layout: lines overflow and clip, onTextLayout reports stale widths (0.86) #57512

Description

@v-metricsadmin

Description

On iOS with the New Architecture, when the user changes the Dynamic Type size while the app is running, already-rendered <Text> keeps its stale line breaks and box heights while glyphs draw at the new scale. Lines run past the right edge of the screen and are clipped mid-glyph. onTextLayout continues to report the stale (fitting) line widths, so the app cannot even detect the overflow programmatically. Killing and relaunching the app at the same Dynamic Type size renders the identical tree correctly.

This looks like the same stale-cached-text-measurement mechanism as #45857 (fixed via #45978) and is adjacent to #45655 (no re-render on font scale change), but it still reproduces on 0.86.0.

Observations:

  • Plain <View>/<Text> trees reproduce it — no third-party code involved (main branch of the reproducer).
  • The with-reanimated branch (same screen with the cards wrapped in react-native-reanimated Animated.Views, mirroring the production layout where this was first observed) reproduces identically. In production this also manifested as some text "freezing" at the pre-change font scale while other text scaled — i.e. mixed text sizes on one screen.
  • Android is unaffected.
  • Fresh mounts after a cold start are always correct; the defect only appears when the content size category changes during the app's lifetime.
  • Originally observed in production on RN 0.83 (Expo SDK 55) as paragraphs of rich text drawing past the right viewport edge whenever Dynamic Type was above default; no combination of flexShrink, explicit width, two-pass mount after onLayout, or removing onPress/accessibilityRole from nested spans avoided it.

Steps to reproduce

  1. npm install && npx expo run:ios --configuration Release (reproducer repo below)
  2. Launch on an iOS simulator at the default text size — everything renders correctly (banner shows fontScale 1.000 · 0 overflowing).
  3. While the app is running: xcrun simctl ui booted content_size extra-extra-large
  4. Observe: paragraphs draw past the right content edge (marked by a red hairline) with stale line breaks; the two cards clip their text; the banner still claims 0 overflowing because onTextLayout returns stale line widths.
  5. Kill and relaunch the app — the identical tree renders correctly at fontScale 1.235.

React Native Version

0.86.0

Affected Platforms

Runtime - iOS

Areas

Fabric - The New Renderer

Output of npx @react-native-community/cli info

System:
  OS: macOS 26.3.1
  CPU: (8) arm64 Apple M1
  Memory: 355.03 MB / 16.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 22.20.0
    path: /usr/local/bin/node
  Yarn: Not Found
  npm:
    version: 10.9.3
    path: /usr/local/bin/npm
  Watchman: Not Found
Managers:
  CocoaPods:
    version: 1.16.2
    path: /opt/homebrew/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 25.5
      - iOS 26.5
      - macOS 26.5
      - tvOS 26.5
      - visionOS 26.5
      - watchOS 26.5
IDEs:
  Xcode:
    version: 26.6/17F113
npmPackages:
  react: 19.2.0
  react-native: 0.86.0
Metro: bundled (Release build, no dev server)

Stacktrace or Logs

No crash — layout-only defect. The measurement API returns false negatives:
after the runtime Dynamic Type change, text visibly overflows the container,
yet onTextLayout still reports every line as fitting inside the Yoga box
("0 overflowing" in the reproducer's banner), because the reported line
widths are the pre-change values.

Reproducer

https://github.com/v-metricsadmin/rn-ios-dynamic-type-stale-text-layout

Screenshots and Videos

Fresh launch @ default (correct) Runtime change to XXL (broken) Relaunch @ XXL (correct)
baseline broken relaunch

Same defect on the with-reanimated branch (production-fidelity variant) — cards stop wrapping and clip after the runtime change:

reanimated-broken

Activity

  1. noshitsherlock commented on Aug 10, 2026

    @noshitsherlock

    Independent confirmation, plus one isolation result that may point at the missing step.

    Still reproduces on 0.86.2 (latest stable) and on 0.85.3, in a bare @react-native-community/cli init project with no third-party dependencies.

    Also reproduces on physical hardware: iPhone16,2 / iOS 26.6, Release build with an embedded bundle — no Metro, dev client or dev overlay involved. To be precise about which build produced which evidence, the device run was 0.85.3; the 0.86.2 runs were on the simulator. The simulator numbers are identical across the two versions.

    Worth noting for triage: on device it reproduces at fontScale 1.353, reachable on the ordinary Larger Text slider — enabling "Larger Accessibility Sizes" is not required, so the exposure is wider than accessibility-range sizes.

    What unsticks a node

    Four Text nodes with identical content, differing only in what changes about them across the transition, each reporting its own onLayout height:

    case what differs h @ 1.000 h @ 3.571
    A nothing 20.3 20.3 — stale
    B key={fontScale} (remount) 20.3 145.0
    C JS-driven fontSize 20.3 145.0
    D accessibilityLabel only — no layout prop 20.3 145.0

    Same on device, 0.85.3 Release (0.823 → 1.353): A stays 17.0; B, C and D all go to 27.7.

    Case D is the interesting one. No layout-affecting prop changes, yet the node re-measures correctly. So it isn't that the new multiplier fails to reach the node — any committed prop update is enough to make it re-measure. That suggests the missing step for case A is dirtying already-built paragraph content when the multiplier changes, rather than anything about the multiplier plumbing itself.

    Consistent with that, two candidate explanations appear already handled in 0.85.3/0.86.x: the text measure cache key includes fontSizeMultiplier, and the iOS Fabric path does receive UIContentSizeCategoryDidChangeNotification and calls constraintLayout with the new multiplier. (Reported as observations, not as a diagnosis of the internals.)

    Scope notes matching the original report

    • Newly mounted subtrees are correct; the JS side is fine throughout — useWindowDimensions().fontScale, Dimensions.get('window').fontScale and PixelRatio.getFontScale() all report the new value, Dimensions emits its change events, and React re-renders.
    • Navigating away and back does not repair a screen that stays mounted (e.g. a tab screen). Only a remount or relaunch does.

    Possibly relevant prior art

    Android has "Relayout surfaces on font scale change" (#53770), changelog [ANDROID][FIXED] - Request layout on attached surfaces when font scale changes, behind the enableFontScaleChangesUpdatingLayout() feature flag. Is an iOS counterpart planned — ideally under the same flag — or is the iOS path considered already covered?

    Reproducer

    App.tsx in a stock npx @react-native-community/cli init <name> --version 0.86.2 project:

    import React, {useState} from 'react';
    import {LayoutChangeEvent, ScrollView, Text, View, useWindowDimensions} from 'react-native';
    
    const SAMPLE = 'Handgloves 123';
    
    // Module scope, NOT inside App: defined inline it is a new component type every
    // render, which remounts its children and makes every case spuriously fresh.
    function Case({id, note, height, onHeight, children}: {
      id: string; note: string; height?: number;
      onHeight: (h: number) => void; children: React.ReactNode;
    }) {
      return (
        <View style={{borderWidth: 1, borderColor: '#000', margin: 8, padding: 6}}>
          <Text allowFontScaling={false} style={{fontSize: 11, color: '#000'}}>
            {id} · {note} · h={height == null ? '…' : height.toFixed(1)}
          </Text>
          <View onLayout={(e: LayoutChangeEvent) => onHeight(e.nativeEvent.layout.height)}>
            {children}
          </View>
        </View>
      );
    }
    
    export default function App() {
      const {fontScale} = useWindowDimensions();
      const [h, setH] = useState<Record<string, number>>({});
      const on = (id: string) => (height: number) =>
        setH(p => (p[id] === height ? p : {...p, [id]: height}));
    
      return (
        <ScrollView contentContainerStyle={{paddingTop: 80, backgroundColor: '#FFF', minHeight: '100%'}}>
          <Text allowFontScaling={false} style={{fontSize: 14, marginLeft: 8, color: '#000'}}>
            fontScale {fontScale.toFixed(3)}
          </Text>
    
          <Case id="A" note="nothing changes" height={h.A} onHeight={on('A')}>
            <Text style={{fontSize: 17, color: '#000'}}>{SAMPLE}</Text>
          </Case>
    
          <Case id="B" note="key={fontScale}" height={h.B} onHeight={on('B')}>
            <Text key={fontScale} style={{fontSize: 17, color: '#000'}}>{SAMPLE}</Text>
          </Case>
    
          <Case id="C" note="JS-driven fontSize" height={h.C} onHeight={on('C')}>
            <Text allowFontScaling={false} style={{fontSize: 17 * fontScale, color: '#000'}}>{SAMPLE}</Text>
          </Case>
    
          <Case id="D" note="non-layout prop only" height={h.D} onHeight={on('D')}>
            <Text accessibilityLabel={`sample at ${fontScale}`} style={{fontSize: 17, color: '#000'}}>{SAMPLE}</Text>
          </Case>
        </ScrollView>
      );
    }

    Run it, note the four h= values, then background the app, change the system text size, and foreground it without relaunching.

  2. JoaoPauloCMarra commented on Sep 30, 2026

    @JoaoPauloCMarra

    I traced a possible second-stage state-reconciliation path beyond the root-layout fix in #57124. ParagraphShadowNode::shouldNewRevisionDirtyMeasurement currently checks only props, while YogaLayoutableShadowNode::completeClone passes the clone rather than the original source node. A state-only clone can inherit clean measurement while adopting an attributed string with a different base font scale.

    This is a source-supported hypothesis, not a confirmed current-main runtime regression. Main also enables the context-change font-scale guard, so a useful native test should isolate state cloning and verify the final reflow under that default. A counting TextLayoutManager and same-scale/changed-scale clone controls look feasible. What is the supported public GTest registration/runner for a new paragraph test beside BaseTextShadowNodeTest.cpp? I am holding the paragraph PR until that native regression is executed.

  3. jaezinpark commented on Oct 4, 2026

    @jaezinpark

    Follow-up with an independent second reproduction and a tagged native trace. This adds runtime evidence for the state-only clone path discussed in the previous comment; it is not a native unit test or a framework-fix claim.

    Tested on iOS 26.5 with React Native 0.86.2 / Fabric, React 19.2.3 and Expo SDK 57. The isolated JS project uses only React Native View, Text, Pressable and useWindowDimensions. The native host was an existing development-client binary reporting RN 0.86.2, not a separately rebuilt minimal native binary.

    With a 20pt Text containing “Reading garden sample.” in a 354pt-wide wrapper:

    • Fresh AX5: height ~256pt.
    • AX5 → large, with no React host mutation: native measurement becomes 24pt.
    • Increment an unrelated sibling counter: height returns to ~256pt while fontScale remains 1.
    • Reverse direction: fresh large → AX5 measures ~256pt, then the counter commit restores 24pt and clips the glyphs.
    • A wrapper key={fontScale} remount maintains the correct height in both directions and after two additional counter commits.

    The 1.5 threshold is not required. Displaying fontScale in a sibling status Text makes the regression immediate; hiding that value separates the native update from the later counter commit.

    A tagged LLDB trace correlates the sample's findNodeHandle tag (34) with the native ShadowNodeFamily::tag. On AX5 → large, ParagraphShadowNode::measureContent runs for tag 34 and the height becomes 24pt. On the next React commit, tag 34 reaches shouldNewRevisionDirtyMeasurement from ShadowNode::clone → progressState, with fragment.props == null, fragment.children == null and non-null state. The source predicate evaluates false; no further measureContent call occurs for tag 34, and a delayed native measurement returns ~256pt.

    Relevant v0.86.2 source: ShadowTree.cpp, YogaLayoutableShadowNode.cpp, and ParagraphShadowNode.cpp. This narrows the reproduced failure to the state-only reconciliation clone not invalidating the sample's old measurement. The exact Yoga dirty bit and cached paragraph contents were not read.

    The shared TextMeasureCache includes fontSizeMultiplier in its key, so this trace does not support a missing scale field in that cache. An initial trace helper decoded an unrelated context-scale field with the wrong iOS ABI; that field was excluded from the analysis.

  4. lh-kyoko commented on Oct 8, 2026

    @lh-kyoko

    Independent repro on 0.86.3, plus a version comparison: the same app does not reproduce on 0.87.1.

    Setup: stock npx @react-native-community/cli init --version 0.86.3, only App.tsx changed (a few static <Text>s and one counter <Text>). iPhone 16 simulator, iOS 26.5, Release build, no Metro. The app does not subscribe to useWindowDimensions, so nothing re-renders when the text size changes.

    Steps

    1. Launch at large.
    2. xcrun simctl ui booted content_size accessibility-extra-extra-extra-large (also via Settings and coming back — same result).
    3. 15 s later the app updates only the counter <Text> (one React commit).

    0.86.3: after step 2 everything is laid out correctly at the new size (the native re-measure works). After step 3, every <Text> whose props did not change goes back to the box measured at the old scale and is drawn clipped at the new one; the counter itself is correct. The reverse direction (AX5 → large) leaves the unchanged texts in their huge AX5 boxes. This matches @jaezinpark's trace above (state-only clone in progressState, no new measureContent).

    0.87.1 (same App.tsx): correct after step 3, in all three variants (foreground change, change via Settings, reverse direction).

    after step 2 after step 3 (one unrelated commit)
    0.86.3 correct unchanged <Text>s clipped to the old boxes
    0.87.1 correct correct

    I haven't bisected, but #57246 ("Fix Fabric reusing nodes with a stale font scale", 45904c8668, landed on main 2026-06-18) looks like the fix: it moves the font-scale comparison into configureYogaTree, so a node that still carries a stale fontSizeMultiplier is dirtied on every commit, not only in SurfaceHandler::constraintLayout. It is in v0.87.0+ but not on 0.86-stable (checked with the compare API), which would explain why everyone in this thread still sees it on 0.86.x.

    If that's right, this issue could be closed as fixed in 0.87, or #57246 picked into 0.86 if another 0.86 patch release is planned. Workaround for 0.86 apps: remount the affected subtree with key={fontScale} (from a Dimensions change listener), as noted above.

    I can share the minimal repro app and the screenshots/recordings (0.86.3 vs 0.87.1) if that helps.

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