Skip to content

Bug: Multiline TextInput does not scroll in Fabric (New Architecture) on macOS #2905

@AlkanV

Description

@AlkanV

Environment

- **react-native -v:** 18.0.0
- **npm ls react-native-macos:** react-native-macos@0.79.4
- **node -v:** v18.19.0
- **npm -v:** 10.2.3
- **yarn --version:** 1.22.10
- **xcodebuild -version:** Xcode 16.0 (Build version 16A242d)
- **react-native-macos:** 0.79.4
- **Architecture:** Fabric (New Architecture)
- **macOS:** 14.x+ (Sonoma)

Steps to reproduce the bug

  1. Create a multiline TextInput with a fixed height in a Fabric-enabled react-native-macos app:
<View style={{ height: 200, borderWidth: 1 }}>
  <TextInput
    multiline
    scrollEnabled={true}
    style={{ flex: 1 }}
    defaultValue="Line 1\nLine 2\nLine 3\n..."  // enough text to overflow 200px
  />
</View>
  1. Type or paste enough text to overflow the 200px visible area
  2. Try to scroll within the TextInput to see overflowed text

Expected Behavior

The multiline TextInput should scroll vertically when text content exceeds the fixed height, just as it does on iOS and in the Paper (Old Architecture) implementation on macOS. The vertical scrollbar should appear and the user should be able to scroll through all text content.

Actual Behavior

The text overflows but no scrolling is possible. The vertical scrollbar never appears. Text below the visible area is clipped and inaccessible. The scrollEnabled={true} prop has no effect.

Additionally, scrollCursorIntoView is unimplemented on macOS (has a // TODO comment at line ~959 of RCTTextInputComponentView.mm), so the cursor does not auto-scroll into view when typing beyond the visible area.

Root Cause Analysis

The issue is in RCTTextInputComponentView.mm. The _setupScrollViewForMultilineTextView method correctly creates an NSScrollView (RCTUIScrollView) wrapper with an RCTClipView and embeds the NSTextView (RCTUITextView) as the document view. However, the NSTextView (document view) never expands beyond the NSScrollView's visible bounds, so NSScrollView sees no overflow and never enables scrolling.

The problem in updateLayoutMetrics:oldLayoutMetrics:

if (_multiline && _scrollView) {
    _scrollView.frame =
        UIEdgeInsetsInsetRect(self.bounds, RCTUIEdgeInsetsFromEdgeInsets(layoutMetrics.borderWidth));
    _backedTextInputView.textContainerInset =
        RCTUIEdgeInsetsFromEdgeInsets(layoutMetrics.contentInsets - layoutMetrics.borderWidth);
}

Only the scroll view frame and text container insets are set. The NSTextView (document view) frame is never explicitly sized to match the actual text content height. Fabric's Yoga layout system constrains self.bounds based on the component's style height, and the NSTextView frame ends up matching the scroll view bounds exactly — meaning there is zero overflow for NSScrollView to scroll.

For NSScrollView to scroll, the document view (NSTextView) must be taller than the clip view. This is a fundamental AppKit requirement.

In contrast, the Paper implementation (RCTMultilineTextInputView.mm) doesn't have this issue because it lets AppKit's NSTextView autoresizing (verticallyResizable = YES) handle the document view sizing naturally, without Yoga-computed frame constraints.

Secondary issue: type casting

In _setupScrollViewForMultilineTextView, the code accesses NSTextView-specific properties (verticallyResizable, horizontallyResizable, textContainer) directly on _backedTextInputView, which is typed as NSView<RCTBackedTextInputViewProtocol> *. This compiles in the current code but would fail if strict type checking were enabled. These properties require a cast to NSTextView *.

Tertiary issue: scrollCursorIntoView unimplemented

- (void)scrollCursorIntoView
{
#if !TARGET_OS_OSX
  // ... iOS implementation ...
#else // [macOS
  // TODO
#endif // macOS]
}

Proposed Fix

1. updateLayoutMetrics:oldLayoutMetrics: — Force NSTextView to expand to content height

After setting the scroll view frame, compute the actual text layout height and set the NSTextView frame accordingly:

if (_multiline && _scrollView) {
    CGRect scrollFrame =
        UIEdgeInsetsInsetRect(self.bounds, RCTUIEdgeInsetsFromEdgeInsets(layoutMetrics.borderWidth));
    _scrollView.frame = scrollFrame;
    _backedTextInputView.textContainerInset =
        RCTUIEdgeInsetsFromEdgeInsets(layoutMetrics.contentInsets - layoutMetrics.borderWidth);

    if ([_backedTextInputView isKindOfClass:[NSTextView class]]) {
      NSTextView *textView = (NSTextView *)_backedTextInputView;
      CGFloat scrollWidth = scrollFrame.size.width;
      textView.minSize = NSMakeSize(scrollWidth, scrollFrame.size.height);
      textView.maxSize = NSMakeSize(scrollWidth, CGFLOAT_MAX);
      textView.frame = NSMakeRect(0, 0, scrollWidth, scrollFrame.size.height);

      NSLayoutManager *lm = textView.layoutManager;
      NSTextContainer *tc = textView.textContainer;
      [lm ensureLayoutForTextContainer:tc];
      NSRect usedRect = [lm usedRectForTextContainer:tc];
      NSSize insets = textView.textContainerInset;
      CGFloat contentHeight = MAX(scrollFrame.size.height, usedRect.size.height + insets.height * 2);
      NSRect textFrame = textView.frame;
      textFrame.size.height = contentHeight;
      textView.frame = textFrame;
    }
}

2. _setupScrollViewForMultilineTextView — Add proper type cast

if ([_backedTextInputView isKindOfClass:[NSTextView class]]) {
    NSTextView *textView = (NSTextView *)_backedTextInputView;
    textView.verticallyResizable = YES;
    textView.horizontallyResizable = YES;
    textView.textContainer.containerSize = NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX);
    textView.textContainer.widthTracksTextView = YES;
}

3. scrollCursorIntoView — Implement macOS version

#else // [macOS
  NSRange selectedRange = [_backedTextInputView selectedRange];
  [_backedTextInputView scrollRangeToVisible:selectedRange];
#endif // macOS]

Note on NSTextView.textContainerInset

On macOS, NSTextView.textContainerInset returns NSSize (not NSEdgeInsets as on iOS). The width component is the horizontal inset and height is the vertical inset.

Reproducible Demo

Minimal reproduction — any Fabric-enabled react-native-macos 0.79.4 app with:

import React from 'react';
import { View, TextInput } from 'react-native';

export default function App() {
  return (
    <View style={{ padding: 40 }}>
      <View style={{ height: 200, borderWidth: 1, borderColor: '#ccc', borderRadius: 8 }}>
        <TextInput
          multiline
          scrollEnabled={true}
          style={{ flex: 1, padding: 10 }}
          defaultValue={Array.from({ length: 50 }, (_, i) => `Line ${i + 1}: Some text content here`).join('\n')}
        />
      </View>
    </View>
  );
}

Additional context

Additional context

  • The Paper (Old Architecture) implementation in RCTMultilineTextInputView.mm does not have this issue — scrolling works correctly because AppKit's native NSTextView autoresizing handles document view sizing without Yoga frame constraints.
  • RCTUIScrollView.scrollEnabled is a stored property with no behavioral effect on macOS — it never actually enables/disables scrolling on the NSScrollView. The actual scroll blocking is done via RCTClipView.constrainScrolling, which defaults to NO (scrolling allowed). So the scroll infrastructure is in place — the NSTextView just never grows beyond the visible area.
  • We are using this fix in production via patch-package. Happy to submit a PR if this approach is accepted.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions