Why programmatic inserts don't wrap on iOS multiline TextInput, and the one-line native fix

작성자

카테고리:

← 피드로
DEV Community · Dheeraj Akula · 2026-08-17 개발(SW)
Cover image for An iOS multiline TextInput that wouldn't wrap, and the one-line fix

Dheeraj Akula

Ran into this last week on a multiline TextInput and lost a chunk of a day to it. Writing it up in case someone else hits the same thing.

The bug

I’ve got a multiline TextInput hooked up with react-native-controlled-mentions and a suggestions dropdown above it. You type a trigger character like @, pick a suggestion, and the library inserts a styled name into the text box. Short insertions are fine. But once an inserted mention pushes the line past the right edge, the input stops resizing on iOS.

The text is all still there. Selection works, select-all grabs everything, the cursor sits where you’d expect. But the wrapped second line gets clipped, because the container is still sized for one line even though there are now two lines of text in it.

The moment the user types anything, even a space, the input resizes and the second line shows up correctly. So it’s not a persistent broken state, just a gap between the programmatic insert and the next keystroke.

The folklore fix

Search “multiline TextInput not wrapping iOS” and you land on facebook/react-native#5213, filed and closed in 2016 and linked from every Stack Overflow answer on the topic. The accepted workarounds: remount with a key prop, toggle scrollEnabled, add alignSelf: 'flex-start', or blur() then focus().

I reached for the key remount. It worked, technically. But every time a mention wrapped to a new line, the keyboard dismissed for a frame and re-appeared. A visible flicker, and it got worse under LayoutAnimation.

Which makes sense in hindsight. Tearing down a native UITextView and rebuilding it inside the same commit is never free. There’s a one-frame window where neither the old nor the new view is focused, iOS notices and starts the keyboard-dismissal animation, and by the time the new input mounts, the keyboard is halfway down. That’s your flicker.

So I went looking for the actual root cause.

Reading the library

I opened the source of react-native-controlled-mentions:

// node_modules/react-native-controlled-mentions/dist/hooks/use-mentions.js
const textInputProps = {
  onChangeText: handleTextChange,
  children: React.createElement(Text, null, mentionState.parts.map(
    ({ text, config, data }, index) => {
      if (!config) return React.createElement(Text, { key: index }, text);
      return React.createElement(Text, { key: ..., style: ... }, text);
    }
  )),
};

Enter fullscreen mode Exit fullscreen mode

The library does not drive the TextInput through the value prop. It passes children: a tree of styled <Text> spans. When you pick a mention, the tree rebuilds and the TextInput receives new children on the next render. You can’t see any of this from the library’s public API, and it turned out to be the detail that mattered.

Why the 2016 fix doesn’t apply

The accepted fix for #5213 added one line to the value-prop setter:

- (void)setText:(NSString *)text {
  _textView.text = text;
  [self updateContentSize]; // <-- the 2016 fix
}

Enter fullscreen mode Exit fullscreen mode

My library never passes value, so setText: never runs. On top of that, the file this patch lives in (RCTTextView.m) is Paper-era, and my project is on Fabric.

On Fabric, a children update flows through _setAttributedString:setAttributedText:textDidChange. I pulled the 0.76-stable source to check, and the whole chain looks like this:

// RCTUITextView.mm
- (void)setAttributedText:(NSAttributedString *)attributedText {
  [super setAttributedText:attributedText];
  [self textDidChange];
}

- (void)textDidChange {
  _textWasPasted = NO;
  [self _invalidatePlaceholderVisibility];
}

Enter fullscreen mode Exit fullscreen mode

No invalidateIntrinsicContentSize. No setNeedsLayout. The attributed string updates and the glyphs are correct, but nothing tells the view its size may have changed. The wrap is computed; the frame it needs does not exist.

On a keystroke this doesn’t matter, because UIKit’s own typing handlers trigger layout internally. On a programmatic children swap, nothing does.

The one-line fix

That changes the problem. The text did update; the view just never re-ran layoutSubviews. That’s a much smaller problem, and it has a much smaller fix.

On iOS, any event that forces UITextView to resolve glyph positions triggers a layout pass. Setting the selection range is one such event: the text view needs to know where the caret sits in glyph coordinates, which means it has to walk the layout manager.

React Native exposes selection as a writable prop, and setNativeProps lets you push it through without a React re-render:

const mentionStateRef = useRef(mentionState);
mentionStateRef.current = mentionState;

// in the dropdown onPress:
triggers.game.onSelect({ id: g.id, name: g.id });
requestAnimationFrame(() => {
  const end = mentionStateRef.current.plainText.length;
  inputRef.current?.setNativeProps({
    selection: { start: end, end },
  });
});

Enter fullscreen mode Exit fullscreen mode

requestAnimationFrame waits for React to commit the children update. setNativeProps then nudges the native view to re-resolve glyph positions, which runs layoutSubviews, which recomputes intrinsic content size, which grows the container, which makes the wrapped line visible.

The caret position I write (plainText.length) is where the mentions library wants the cursor after an insert anyway, so from the user’s perspective nothing visible happens, except the clipping is gone.

No remount, no focus loss, no keyboard flicker. One native round-trip.

I’m Dheeraj, a software engineer at Nutanix Enterprise AI working on agent
harnesses and developer tools. I write up the problems that took me too long to
work out. More at dheerajakula.dev/blog.

원문에서 계속 ↗