From 6c2acc31e33d53922972cb0b708fa796da564289 Mon Sep 17 00:00:00 2001 From: Jakub Adamczyk Date: Thu, 3 Sep 2026 21:36:23 +0200 Subject: [PATCH] Fix Android text mis-measurement when fontVariationSettings are re-applied to a reused Paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `fontVariationSettings` (#57804, #57805) is applied with `Paint.setFontVariationSettings` after `Paint.setTypeface`. Android's `Paint.setFontVariationSettings` returns early when the requested settings string equals the value the paint already holds, and `Paint.setTypeface` does not clear that stored string. On the legacy variation path (every shipped Android release), the variation instance lives on the typeface, so once `setTypeface` replaces the typeface the axes are gone, but the paint still reports the old settings and the second `setFontVariationSettings` call is a no-op. React Native hits this sequence on every measurement after the first. `TextLayoutManager.scratchPaintWithAttributes` reuses a thread-local `TextPaint` and resets it with `setTypeface(null)`, which leaves the stale settings string in place. Any subsequent `` with the same `fontVariationSettings` string is then measured with the un-varied typeface, while the `TextView` draws it through its own paint where the axes apply. The same happens inside `StaticLayout`, whose pooled `MeasuredParagraph`/`TextLine` paints copy the settings string via `Paint.set`. `ReactEditText` has the equivalent problem through `TextView.setFontVariationSettings`, which also skips unchanged values. With a variable font whose default instance is lighter than the requested `wght`, Yoga receives regular-width metrics while semibold glyphs are drawn, so `numberOfLines={1}` text is ellipsized ("Get Start...") and wrapped text breaks in different places than it renders. This change clears the paint's settings before re-applying them in `ReactTypefaceUtils.applyFontVariationSettings` and in `ReactEditText.maybeUpdateTypeface`, so the axes are always rebuilt on the current typeface. Paints without previous settings are unaffected. Changelog: [ANDROID] [FIXED] - Fix text measured with the wrong width when `fontVariationSettings` are re-applied to a reused `Paint` Test Plan: - `./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "*TextLayoutManagerFontWeightAdjustmentTest*" --tests "*ReactTextInputPropertyTest*"`: 8/8 and 27/27 pass. The three new tests cover the reused-paint reset, the fresh-paint path (no spurious clear), and the measurement scratch paint through `TextLayoutManager.updateTextPaint`. Without the production change, `reused paint re-applies unchanged font variation settings to a new typeface` and `scratch text paint re-applies font variation settings on repeated measurement` fail. - `yarn lint-kotlin-check` (`./gradlew ktfmtCheck`): passes. - Manual, Android 17 emulator (API 37), variable Inter font registered via `ReactFontManager.addCustomFont` with entries for weights 100–900, ``: - Before: the second and later semibold texts are measured as regular and ellipsized; button label rendered as "Get Start...". - After: labels render in full and the measured bounds match the drawn text for `'wght' 600`, `650` and `700`, including repeated identical styles and `TextInput`. Co-Authored-By: Claude Fable 5.1 --- .../react/views/text/ReactTypefaceUtils.kt | 4 ++ .../react/views/textinput/ReactEditText.kt | 4 ++ ...xtLayoutManagerFontWeightAdjustmentTest.kt | 50 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt index 154062b966d5..6253b10db5d0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt @@ -179,6 +179,10 @@ public object ReactTypefaceUtils { } try { + // Paint skips unchanged settings even after setTypeface, so clear them first. + if (paint.fontVariationSettings != null) { + paint.setFontVariationSettings(null) + } paint.setFontVariationSettings(fontVariationSettings) } catch (exception: IllegalArgumentException) { // Paint instances are reused, so explicitly clear axes from a previous layout. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index e27330c87149..6b51c8067f99 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -689,6 +689,10 @@ public open class ReactEditText public constructor(context: Context) : AppCompat val newTypeface = applyStyles(typeface, fontStyle, fontWeight, fontFamily, context.assets) typeface = newTypeface if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // TextView skips unchanged settings even after a typeface change, so clear them first. + if (fontVariationSettings != null) { + super.setFontVariationSettings(null) + } super.setFontVariationSettings(parsedFontVariationSettings) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt index 9b7454b1e0b0..b2df8cfd4c0e 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt @@ -24,6 +24,7 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -127,6 +128,55 @@ class TextLayoutManagerFontWeightAdjustmentTest { verify(paint).setFontVariationSettings(null) } + @Test + fun `reused paint re-applies unchanged font variation settings to a new typeface`() { + val paint = mock() + whenever(paint.fontVariationSettings).thenReturn("'wght' 600") + + ReactTypefaceUtils.applyFontVariationSettings(paint, "'wght' 600") + + inOrder(paint) { + verify(paint).setFontVariationSettings(null) + verify(paint).setFontVariationSettings("'wght' 600") + } + } + + @Test + fun `fresh paint applies font variation settings without clearing first`() { + val paint = mock() + whenever(paint.fontVariationSettings).thenReturn(null) + + ReactTypefaceUtils.applyFontVariationSettings(paint, "'wght' 600") + + verify(paint).setFontVariationSettings("'wght' 600") + verify(paint, never()).setFontVariationSettings(null) + } + + @Test + fun `scratch text paint re-applies font variation settings on repeated measurement`() { + val paint = mock() + whenever(paint.fontVariationSettings).thenReturn("'wght' 600") + val textAttributes = + TextAttributeProps.fromReadableMap( + ReactStylesDiffMap( + JavaOnlyMap.of("fontWeight", "600", "fontVariationSettings", "'wght' 600"), + ), + ) + + TextLayoutManager.updateTextPaint( + paint, + textAttributes, + RuntimeEnvironment.getApplication().assets, + 0, + ) + + inOrder(paint) { + verify(paint).setTypeface(any()) + verify(paint).setFontVariationSettings(null) + verify(paint).setFontVariationSettings("'wght' 600") + } + } + private companion object { const val FONT_WEIGHT_ADJUSTMENT_BOLD_TEXT = 300 }