Why programmatic inserts don't wrap on iOS multiline TextInput, and the one-line native fix
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 @ to pick a suggestion from a dropdown, and the library inserts a styled name into the text box. Short insertions are fine but once the text is long enough that the inserted mention pushes the line past the right edge, and the input stops resizing on iOS.
The text is still there. Selection works, select-all grabs everything, the cursor sits where you'd expect. But the wrapped line renders outside the input's gets clipped entirely. The container is still sized for one line even though there's two lines of text in it.
As soon as 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. But every time a mention wrapped to a new line, the keyboard dismissed for a frame and re-appeared. A visible flicker that got worse under LayoutAnimation.
Tearing down a native UITextView and rebuilding it inside the same commit is never free. There is a one-frame window where neither the old nor the new view is focused. iOS notices and starts the keyboard-dismissal animation. By the time the new input mounts, the keyboard is halfway down. That is your flicker.
So I went looking for the actual root cause.
Reading the library
I was using react-native-controlled-mentions. Opened its source:
// 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);
}
)),
};
The library does not drive the TextInput via 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. This detail is invisible from the library's public API, and it is the one that matters.
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
}
The library never passes value, so setText: never runs. And the file this patch lives in (RCTTextView.m) is Paper-era; 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];
}
No invalidateIntrinsicContentSize. No setNeedsLayout. The attributed string updates, 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 does not matter: UIKit's own typing handlers trigger layout internally. On a programmatic children swap, nothing does.
What I actually needed
The diagnosis reframes the problem. It is not "the text didn't update"; the text did update. It is "the view did not re-run layoutSubviews." That is a strictly smaller problem, and it has a strictly 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 },
});
});
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, so from the user's perspective nothing visible happens, except that the clipping is gone.
No remount. No focus loss. No keyboard flicker. One native round-trip.