Skip to content

Fix Android text measurement to use the rendering TextView default typeface - #58036

Open
pangziqiang wants to merge 1 commit into
react:mainfrom
pangziqiang:fix/android-text-measurement-system-default-typeface
Open

pangziqiang wants to merge 1 commit into
react:mainfrom
pangziqiang:fix/android-text-measurement-system-default-typeface

Conversation

@pangziqiang

@pangziqiang pangziqiang commented Aug 20, 2026 •

Copy link
Copy Markdown

Summary

Auto-width <Text> can clip its last glyph(s) on devices where the system default font is replaced/bolded at the theme level (e.g. Xiaomi HyperOS "全加粗" / a font replacement app). Measurement uses a bare TextPaint whose default typeface is Typeface.DEFAULT (the static normal font), while ReactTextView renders with the TextView default typeface that inherits the system font configuration. When those diverge, the measured width is narrower than what is actually drawn, so the trailing glyph(s) are pushed out of the view.

Fixes #57950.

Changelog:

[Android] [Fixed] - Fix Fabric text measurement to use the rendering TextView's default typeface
when no font family/weight/style is set. Fabric reuses one thread-local TextPaint for all
measurement; `paint.reset(); paint.setTypeface(null)` does not restore the typeface on some ROMs
(reset() keeps the resolved font and the following setTypeface(null) is a no-op), so after any
<Text> with an explicit font (e.g. a bundled icon font for vector icons) every plain <Text> was
measured with that font's metrics - line boxes ~24% too short, descenders/trailing glyphs clipped.

Root cause

Fabric measures every <Text> with a single shared, thread-local TextPaint. For text without an
explicit font family/weight/style, updateTextPaint() restores the default with
paint.reset(); paint.setTypeface(null). That does not reliably restore the typeface:

  • Paint.reset() does not clear the resolved typeface, and
  • Paint.setTypeface(null) is a no-op when the paint's Java-level typeface is already null
    (it short-circuits inside Paint.setTypeface).

So the paint keeps the last typeface that was set explicitly. Instrumented inside the app process
on an affected device (48px, same Paint instance, measureText("A") in px):

step affected device (HyperOS) stock device (AOSP 16)
fresh paint asc -50, desc 14, wA 36.0 asc -45, desc 14, wA 30.0
setTypeface(<icon font>) asc -45, desc 3, wA 33.0 asc -45, desc 3, wA 29.0
reset() asc -45, desc 3 (unchanged) asc -45, desc 3 (unchanged)
setTypeface(null) asc -45, desc 3 (still stale) asc -45, desc 14 (restored)

Any app that sets an explicit font on some <Text> nodes then poisons the measurement of every
plain <Text>. The most common trigger is react-native-vector-icons: its bundled icomoon.ttf has a
1.000 em line box and a 0.0625 em descender, so measured text gets a ~1.02 em box while
ReactTextView draws with the system font at ~1.33 em — the last glyphs / descenders are clipped.
Setting an explicit lineHeight hides it.

This also explains the partial reporting: nodes with explicit font attributes take the other branch of
updateTextPaint() and measure correctly, so a single fontSize could produce two different line
boxes on one screen (48px text: 49.7/23.7/73.3 px and 50.3/13.7/64.0 px on an affected device before
the fix).

Note that on stock Android the accessibility "bold text" setting (fontWeightAdjustment) updates the
process-wide Typeface.DEFAULT, so the bare measurement paint picks up the same metrics and
measurement/rendering agree there — which is why this cannot be reproduced on stock emulators.

Change

When no explicit font family/weight/style is set and there is no font weight adjustment, resolve the default typeface from the same source the rendering TextView uses:

} else {
  val typeface = ReactTypefaceUtils.applyFontWeightAdjustment(null, fontWeightAdjustment)
  if (typeface != null) {
    paint.setTypeface(typeface)
  } else {
    paint.setTypeface(getSystemDefaultTypeface(context))
  }
}

getSystemDefaultTypeface() reads the typeface of a single cached TextView created from the application context (no per-measure allocation, no activity leak) and re-resolves it whenever the Configuration changes (fontScale / fontWeightAdjustment / locale). A Context is threaded from FabricUIManager (which already threads assets and fontWeightAdjustment) down to updateTextPaint.

On unmodified systems the TextView default typeface is the same normal face, so behavior and measurements are unchanged.

Test plan

  • Updated TextLayoutManagerFontWeightAdjustmentTest to assert that, without font weight adjustment, the measurement typeface equals the rendering TextView default typeface.
  • Verified on 5 Android devices including 3 Xiaomi phones/tablets with the bold system font enabled: English measures 57dp == drawn 56.67dp, all 7 glyphs present; time/CJK text unaffected; layouts remain correct.

Device verification (unpatched vs patched vs old architecture)

Two affected devices, RN 0.78.3 with this change backported (TextLayoutManager is still Java on the
0.78 branch). Native line metrics from onTextLayout (dp: ascender/descender/height):

device / density fontSize 0.78.3 unpatched 0.78.3 + this change 0.73.11 old arch
Xiaomi 17 Max, 480 12 11.667 / 1.000 / 12.667 12.667 / 3.667 / 16.333 12.667 / 3.667 / 16.333
13 12.667 / 1.000 / 13.667 (and 13.667 / 3.667 / 17.333) 13.667 / 3.667 / 17.333 13.667 / 3.667 / 17.333
16 15.333 / 1.000 / 16.333 17.000 / 4.667 / 21.667 17.000 / 4.667 / 21.667
19 18.333 / 1.333 / 19.667 20.000 / 5.667 / 25.667 20.000 / 5.667 / 25.667
29 27.667 / 2.000 / 29.667 30.333 / 8.333 / 38.667 30.333 / 8.333 / 38.667
Xiaomi Pad 6S Pro, 400 15 14.800 / 1.200 / 16.000 16.000 / 4.400 / 20.400 16.000 / 4.400 / 20.400
16 15.600 / 1.200 / 16.800 16.800 / 4.800 / 21.600 16.800 / 4.800 / 21.600
19 18.400 / 1.200 / 19.600 20.400 / 5.600 / 26.000 20.400 / 5.600 / 26.000
23 22.400 / 1.600 / 24.000 24.400 / 6.800 / 31.200 24.400 / 6.800 / 31.200
26 24.800 / 2.000 / 26.800 27.200 / 7.600 / 34.800 27.200 / 7.600 / 34.800

Every patched value equals the 0.73.11 old-architecture reference exactly (6/6 and 5/5 samples).
Descender goes from ~0.08 em back to ~0.29 em and the line box from ~1.02 em back to ~1.36 em.
A third device with an unmodified ROM (AOSP 16) does not reproduce and the change is a behavioural
no-op there.

A minimal reproducer (no bundled font required) is in
#57950 (comment)

Notes for reviewers

  • The typeface is read from the application context's default theme, which reproduces the rendering TextView on the affected devices. Devices that apply theme-dependent default fonts per-Activity may need the themed context instead.
  • Static caching is invalidated on Configuration change; a live font-family replacement that does not change Configuration would still need an app restart to re-apply, which is the normal case.
  • This change only affects measurement; it does not change rendering. The maintainer offered to collaborate on the verification path since this is hard to reproduce on stock emulators.

@meta-cla

meta-cla Bot commented Aug 20, 2026

Copy link
Copy Markdown

Hi @pangziqiang!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@pangziqiang

Copy link
Copy Markdown
Author

The CLA is signed now — the reproducer PR #58783 already picked up the CLA Signed label. This one was opened before signing, could the bot re-check it?

…peface

Auto-width <Text> can clip its last glyph(s) when the system default font is
replaced/bolded at the theme level (e.g. a bold system font or a font
replacement app). Measurement resets the TextPaint typeface to Typeface.DEFAULT,
while ReactTextView renders with the TextView default typeface that inherits the
system font configuration. When those diverge, the measured width is narrower
than what is drawn, so trailing glyphs get pushed out of the view.

Update updateTextPaint so that, when no explicit font family/weight/style is set
and there is no font weight adjustment, the measurement paint uses the same
default typeface the rendering TextView uses. The typeface is resolved from a
single cached TextView (application context, no per-measure allocation) and
re-resolved when the Configuration changes.
@pangziqiang
pangziqiang force-pushed the fix/android-text-measurement-system-default-typeface branch from dbf37c3 to da6a2b2 Compare October 1, 2026 06:25
@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 1, 2026
@pangziqiang

Copy link
Copy Markdown
Author

@sbaiahmed1 — this is the patched-build validation you asked for on #57950. You said "the change itself is small; the risk is entirely in verification", so here is the verification.

What was tested

RN 0.78.3 still ships TextLayoutManager as Java on the 0.78 branch, so I backported the change from this PR to 0.78.3, recompiled that one file against the published AAR and repackaged the AAR. Control for the recompile: rebuilding the unpatched file reproduces the published class byte-for-byte (javap -p -c diff is empty), so the recompile itself is not a variable. Same app JS for every run, only the react-android classes change.

Devices

Both affected, both HyperOS with a theme-level font replacement (MI Lan Pro VF 小米兰亭 Pro at the heaviest weight, identical theme font file on both, md5 1d0a27abd80dcefb1ec64c3934e767bb):

  • Xiaomi 17 Max (byron, 2605EPN8EC), API 37, density 480
  • Xiaomi Pad 6S Pro (sheng, 24018RPACC), API 37, density 400

Result — native line metrics from onTextLayout (dp: ascender / descender / height):

device fontSize 0.78.3 unpatched 0.78.3 + this change 0.73.11 old arch
17 Max 12 11.667 / 1.000 / 12.667 12.667 / 3.667 / 16.333 12.667 / 3.667 / 16.333
13 12.667 / 1.000 / 13.667 (and 13.667 / 3.667 / 17.333) 13.667 / 3.667 / 17.333 13.667 / 3.667 / 17.333
16 15.333 / 1.000 / 16.333 17.000 / 4.667 / 21.667 17.000 / 4.667 / 21.667
19 18.333 / 1.333 / 19.667 20.000 / 5.667 / 25.667 20.000 / 5.667 / 25.667
29 27.667 / 2.000 / 29.667 30.333 / 8.333 / 38.667 30.333 / 8.333 / 38.667
Pad 6S Pro 15 14.800 / 1.200 / 16.000 16.000 / 4.400 / 20.400 16.000 / 4.400 / 20.400
16 15.600 / 1.200 / 16.800 16.800 / 4.800 / 21.600 16.800 / 4.800 / 21.600
19 18.400 / 1.200 / 19.600 20.400 / 5.600 / 26.000 20.400 / 5.600 / 26.000
23 22.400 / 1.600 / 24.000 24.400 / 6.800 / 31.200 24.400 / 6.800 / 31.200
26 24.800 / 2.000 / 26.800 27.200 / 7.600 / 34.800 27.200 / 7.600 / 34.800

Every patched value equals the 0.73.11 old-architecture reference exactly (6/6 samples on the phone, 5/5 on the tablet). Descender goes from ~0.08 em back to ~0.29 em, the line box from ~1.02 em back to ~1.36 em. No FATAL / NoSuchMethodError / VerifyError; the injected classes stay binary compatible with the rest of the AAR. On a third device with an unmodified ROM (Xiaomi Pad 4 Plus, stock AOSP 16, density 320) the bug does not reproduce and the change is a behavioural no-op, which is the expected negative control.

Reproducer — #58783 adds a minimal RNTester Playground reproducer that needs no bundled font asset, plus the exact numbers it prints on affected / patched / stock devices.

The full write-up (shared measurement-paint instrumentation, the reset() / setTypeface(null) step table, raw logcat) is in #57950: #57950 (comment)

Please take a look when you have time.

@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 1, 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.

Android Fabric text measurement uses Typeface.DEFAULT instead of the system default used for rendering, clipping last glyphs with bold system fonts

1 participant