Skip to content

Fix nested Text decorations disappearing from TextInput on Android - #59201

Open
jkasprzyk17 wants to merge 1 commit into
react:mainfrom
jkasprzyk17:fix/android-textinput-nested-decoration
Open

jkasprzyk17 wants to merge 1 commit into
react:mainfrom
jkasprzyk17:fix/android-textinput-nested-decoration

Conversation

@jkasprzyk17

@jkasprzyk17 jkasprzyk17 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

On Android, the underline or line-through of a <Text> nested in a <TextInput> disappears when the input's placeholder changes. The text is still drawn. The decoration comes back only when the input is drawn again for another reason, because by then TextView has rebuilt its layout. It has the same cause as #58999, which reported it for <Text>. #58847 fixes <Text> the same way, and this PR does it for <TextInput>.

A decoration set on the TextInput itself isn't affected: ReactTextInputManager applies it as Paint.UNDERLINE_TEXT_FLAG or Paint.STRIKE_THRU_TEXT_FLAG, which Layout.draw paints. A nested <Text> gets a ReactUnderlineSpan or ReactStrikethroughSpan instead. Those are CanvasEffectSpans, which ReactEditText.onDraw paints before and after super.onDraw using getLayout(). When getLayout() returns null, onDraw falls back to a plain super.onDraw and skips them.

The layout is null at draw time after a placeholder change:

  1. ReactEditText.setPlaceholder sets hint. TextView#setHint calls checkForRelayout(), which can't rebuild the layout in place here. It calls nullLayouts() and requestLayout() instead.
  2. A placeholder change doesn't change the input's frame, so Fabric sends no updateLayout and doesn't measure the view. requestLayout() stops at the parent ReactViewGroup.
  3. The layout stays null until TextView#onDraw rebuilds it in assumeLayout(), which is after ReactEditText has skipped the spans.

With this change, ReactEditText.onDraw measures the view again with its current frame when it has CanvasEffectSpans and the layout is missing, before it draws the spans. These are the same specs Fabric measures the view with in updateLayout, and ReactEditText already re-measures at the current bounds the same way when multiline is turned off. TextView#onDraw would build the same layout anyway, so this adds no layout work. Inputs without CanvasEffectSpans keep the current draw path.

I confirmed the cause with temporary logging in ReactEditText.onDraw, which is not part of this change. After a placeholder change, getLayout() was null in onDraw for the inputs whose nested decorations disappeared. Inserting a sibling after the inputs (the trigger in #58999) and changing fontSize on a fixed-size input both left the layout in place, so they don't reproduce it for <TextInput>.

Changelog:

[ANDROID] [FIXED] - Fix the underline and line-through of a Text nested in a TextInput disappearing when the placeholder changes

Test Plan:

A new Robolectric test, ReactEditTextTest.drawsCanvasEffectSpansAfterPlaceholderChangeThatKeepsTheFrame, lays out a ReactEditText whose text has a CanvasEffectSpan, changes its placeholder without measuring it again, and draws it. It checks that the span got onPreDraw and onDraw. The test fails without this change and passes with it.

./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests 'com.facebook.react.views.textinput.*'
# 29 tests, 0 failures
./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest
# 765 tests, 0 failures
yarn format-check-kotlin
# passes

I also reproduced it in RNTester: Debug build, new architecture, Pixel 8 emulator with Android 15 (API 35).

RNTesterPlayground.js
const UNDERLINE = {textDecorationLine: 'underline'};
const STRIKE = {textDecorationLine: 'line-through'};

function Playground() {
  const [inserted, setInserted] = useState(false);
  const [placeholder, setPlaceholder] = useState(false);
  const [big, setBig] = useState(false);

  const input = {
    width: 320,
    height: 44,
    fontSize: big ? 15 : 14,
    color: '#333',
    borderWidth: 1,
    borderColor: '#ccc',
    marginBottom: 8,
  };

  return (
    <View style={{flex: 1, backgroundColor: '#fff'}}>
      <View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 8, padding: 16}}>
        <Button title={inserted ? 'remove' : 'insert'} onPress={() => setInserted(v => !v)} />
        <Button title={`placeholder ${placeholder ? 'B' : 'A'}`} onPress={() => setPlaceholder(v => !v)} />
        <Button title={`font ${big ? 15 : 14}`} onPress={() => setBig(v => !v)} />
      </View>
      <ScrollView contentContainerStyle={{padding: 16}}>
        <TextInput style={input} placeholder={placeholder ? 'Placeholder B' : 'Placeholder A'}>
          <Text>a nested: before </Text>
          <Text style={UNDERLINE}>underlined</Text>
          <Text> after</Text>
        </TextInput>
        <TextInput style={input} placeholder={placeholder ? 'Placeholder B' : 'Placeholder A'}>
          <Text>b nested: before </Text>
          <Text style={STRIKE}>line-through</Text>
          <Text> after</Text>
        </TextInput>
        <TextInput
          style={[input, UNDERLINE]}
          placeholder={placeholder ? 'Placeholder B' : 'Placeholder A'}
          defaultValue="c own style underline"
        />
        {inserted && <Text>inserted row 1</Text>}
        {inserted && <Text>inserted row 2</Text>}
        <TextInput style={input}>
          <Text>d below: before </Text>
          <Text style={UNDERLINE}>underlined</Text>
          <Text> after</Text>
        </TextInput>
      </ScrollView>
    </View>
  );
}

Steps: tap PLACEHOLDER. Before this change, the nested underline in a and the nested line-through in b disappear. c, whose underline comes from its own style, keeps it, and so does d, which has no placeholder. With this change, both nested decorations stay after each placeholder change. I also focused a and typed: the text and caret behave as before, and the decorations stay after another placeholder change.

before-after
yarn install
yarn --cwd packages/react-native-codegen build
./gradlew :packages:rn-tester:android:app:installDebug -PreactNativeArchitectures=arm64-v8a
yarn start

Not run: iOS and Fantom tests (this is an Android-only Kotlin change), and JS lint or Flow (no JS changes).

Not changed: in the screenshots the nested decorations sit slightly too high in a TextInput, because onDraw translates the spans by the padding only, without the vertical gravity offset. That happens with or without this change. #56767 handles the same offset for <Text>, and I left <TextInput> out of this PR.

ReactEditText draws the underline or line-through of a nested Text as
CanvasEffectSpans around super.onDraw, using getLayout(). Changing the
placeholder makes TextView drop its layout and wait for the next
measure. Fabric measures a TextInput only when its frame changes, so
the layout stays null. onDraw then fell back to super.onDraw, which
rebuilds the layout and draws the text, but skipped the decorations.

When the input has CanvasEffectSpans and its layout is missing at draw
time, measure the view again with its current frame before drawing.
TextView#onDraw would build the same layout anyway.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 19, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 19, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant