Skip to content

[iOS][Fabric] Changing Dynamic Type while the app is running does not re-lay out text (root not dirtied on a layout-context-only change) #59927

Open

Description

@Manuca-Garcia

Description

On iOS with the New Architecture, changing the system text size (Dynamic Type) while the app is running has no visible effect on text already on screen. React Native receives the new fontSizeMultiplier, but no layout pass runs, so text keeps its launch-time size until the app is restarted. Text mounted after the change can pick up the new size, which produces mixed layouts.

Steps to reproduce

  1. Build a Release app with React Native 0.88.1 (New Architecture) and any <Text> that has allowFontScaling left at its default.
  2. Launch it on an iOS simulator or device.
  3. Without closing the app, change the text size (Settings › Display & Brightness › Text Size, or on a simulator xcrun simctl ui <udid> content_size accessibility-extra-large).
  4. Return to the app.

Expected: text re-lays out at the new size.
Actual: nothing changes, in either direction. Relaunching renders the new size correctly.

Root cause

  • RCTFabricSurface handles UIContentSizeCategoryDidChangeNotification by calling _updateLayoutContext with the new fontSizeMultiplier, which reaches SurfaceHandler::constraintLayout. That commits RootShadowNode::clone(propsParserContext, layoutConstraints, layoutContext).

  • RootShadowNode::clone only calls dirtyLayout() when the layout constraints change:

    if (layoutConstraints != getConcreteProps().layoutConstraints) {
      newRootShadowNode->dirtyLayout();
    }
  • For a layout-context-only change, the root stays clean. RootShadowNode::layoutIfNeeded then returns early (getIsLayoutClean()), so layoutTree → configureYogaTree never runs.

  • That means the existing font-scale handling in YogaLayoutableShadowNode::configureYogaTree never gets a chance to run, even though enableFontScaleChangesUpdatingLayout defaults to true. That code marks measurable nodes dirty when their fontSizeMultiplier differs from the new one.

  • pointScaleFactor and swapLeftAndRightInRTL are consumed by configureYogaTree in the same way, so a change to either alone has the same problem.

The same code is on main as of 2026-11-10 (RootShadowNode.cpp last changed in 178c30d). #59285 makes ParagraphShadowNode drop content built for another fontSizeMultiplier, but that only helps once a layout pass actually runs.

Proposed fix

Dirty the root when any layout-context input that configureYogaTree consumes changes:

-  if (layoutConstraints != getConcreteProps().layoutConstraints) {
+  const auto& previousContext = getConcreteProps().layoutContext;
+  if (layoutConstraints != getConcreteProps().layoutConstraints ||
+      layoutContext.fontSizeMultiplier != previousContext.fontSizeMultiplier ||
+      layoutContext.pointScaleFactor != previousContext.pointScaleFactor ||
+      layoutContext.swapLeftAndRightInRTL !=
+          previousContext.swapLeftAndRightInRTL) {
     newRootShadowNode->dirtyLayout();
   }

The full LayoutContext == is deliberately not used: it also compares affectedNodes (a per-pass pointer) and the viewport fields, which would dirty the root on unrelated commits.

I traced this in source and reproduced the symptom in a Release build. I have not run the patched build myself, because our app uses the prebuilt React Native core.

Versions

React Native 0.88.1 (also present on 0.88-stable and main), Expo SDK 58, iOS 26.5 simulator, New Architecture, Hermes.

Activity

  1. react-native-bot commented on Nov 11, 2026

    @react-native-bot
    Collaborator

    Warning

    Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:


    You can read more about about it on our website: How to report a bug.

  2. wbraynen commented on Nov 11, 2026

    @wbraynen

    I tried the proposed RootShadowNode::clone change against main (b9a83f3) with the ReactCommon C++ tests (./gradlew :private:react-native-fantom:testReactCommon). It has no effect: the root already needs layout after every clone(propsParserContext, layoutConstraints, layoutContext), even when nothing changed.

    The reason is that YogaLayoutableShadowNode::layoutTree writes the layout constraints into the root's Yoga style as min/max dimensions. The cloned RootProps starts from a default ViewProps style, so updateYogaProps() sees a different style and marks the root dirty. So a font-scale-only change does reach layoutTree → configureYogaTree.

    A quick check, added to RootShadowNodeTest.cpp, starting from a root that has already been cloned and laid out once:

    auto layoutContext = rootShadowNode->getConcreteProps().layoutContext;
    auto clonedWithSameLayoutContext =
        rootShadowNode->clone(parserContext, layoutConstraints, layoutContext);
    EXPECT_TRUE(clonedWithSameLayoutContext->getIsLayoutClean()); // fails: dirty

    FontScaleLayoutTest in ViewTest.cpp also passes on unmodified main, including a second font-scale change from an already-cloned root.

    The text that stays at its old size looks like the case fixed by #59383 (measurable nodes under a subtree whose owner is already dirty, or under an in-place clone that comes back dirty). That fix is in v0.89.0-rc.0 and later, not in 0.88.x, so it may be worth retesting this issue on 0.89.

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

    Needs: Author FeedbackNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions