Repository navigation
Android Fabric text measurement uses Typeface.DEFAULT instead of the system default used for rendering, clipping last glyphs with bold system fonts #57950
Description
Activity
- addedNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.This issue could be improved with a clear list of steps to reproduce the issue.
on Aug 13, 2026 Warning
Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:
- For majority of bugs: send us a Pull Request with the RNTesterPlayground.js edited to reproduce your bug.
- If your bug is UI related: a Snack
- If your bug is build/upgrade related: a project using our Reproducer Template
You can read more about about it on our website: How to report a bug.- 信件已经收到。
- addedNeeds: AttentionIssues where the author has responded to feedback.Issues where the author has responded to feedback.and removed
on Aug 13, 2026 Some triage data from a stock device, since the suggested root cause implies any system-level font change should trigger this:
I tried to reproduce the measure/render mismatch on a stock emulator (API 35) using Android's built-in Bold text accessibility setting (
settings put secure font_weight_adjustment 300) — the closest stock equivalent to a system-wide font weight change. Result: no clipping. Auto-width<Text>elements (alignSelf: 'flex-start', fontSize 14/24/48) rendered bold and their measured boxes grew to match (e.g. 48px "English" measured width grew ~2.5%, matching the bolder glyphs).That's consistent with how stock Android implements
fontWeightAdjustment: the framework updates the process-wideTypeface.DEFAULT, so a measurementTextPaintwith a null/default typeface picks up the adjustment too — measurement and rendering agree.Which narrows your report to font replacements that are applied at the TextView/theme level without updating
Typeface.DEFAULT— apparently what HyperOS's MiSans "full bold" does. Your on-device paint metrics table already demonstrates the divergence, so the diagnosis looks right; it just can't be reproduced or regression-tested on stock images.Since a fix (deriving the measurement typeface from the same source the rendering
TextViewuses) touches shared text measurement for all Android users, it really needs validation on affected hardware. If you can test a patchedTextLayoutManageron your Xiaomi devices, I'd be happy to collaborate on a PR — the change itself is small; the risk is entirely in verification.Your stock-device result is exactly right, and it let us pin the mechanism down. We reproduced the
same thing at the paint level on two affected devices and validated a patched build there.1. The leak is in the shared measurement paint, not (only) "null vs Typeface.DEFAULT"
Fabric measures every with ONE thread-local TextPaint (
sTextPaintInstance). For text without
explicit fontFamily/fontWeight/fontStyle,updateTextPaint()doespaint.reset(); paint.setTypeface(null).
We instrumented that paint inside the app process (48px, same Paint instance, measureText("A") in px):step HyperOS (affected) AOSP 16 (stock, not affected) R0 fresh paint asc -50, desc 14, top -51, bottom 14, wA36.0asc -45, desc 14, top -45, bottom 14, wA30.0R1 setTypeface(<bundled icon font>)asc -45, desc 3, top -46, bottom 3, wA33.0asc -45, desc 3, top -46, bottom 3, wA29.0R2 reset()asc -45, desc 3, top -46, bottom 3 (still leaked) asc -45, desc 3, top -46, bottom 3 (still leaked) R3 setTypeface(null)asc -45, desc 3 (still leaked) asc -45, desc 14 (restored) R4 asset -> setTypeface(DEFAULT)-> reset ->setTypeface(null)clean clean R5 asset -> setTypeface(null)-> resetclean clean So
Paint.reset()never restores the typeface (R2 on both) and the followingsetTypeface(null)
is a no-op on HyperOS (R3) while it works on AOSP. That is the same divergence your stock emulator
showed withfont_weight_adjustment— the difference is whether the ROM's font change is visible to
Typeface.DEFAULT. Your "narrows to theme-level font replacement" conclusion is confirmed.2. The trigger is any explicitly-set font, not the system font
Because one paint is reused across text nodes, the poisoning needs no theme font at all: any
that explicitly sets a font leaves it on the paint. Our app uses an icon font for vector icons
(assets/fonts/icomoon.ttf, hhea 960/-64 @ upm 1024 = a line box of exactly 1.000 em, descender
0.0625 em). After the first icon , every plain is measured with it:attrs fs=48 family=null -> paint tf=null a=-50/14 wA=36.0 (clean, before any icon text) attrs fs=54 family=icomoon-> paint tf=@48f232c a=-51/3 wA=38.0 (explicit font set) attrs fs=48 family=null -> paint tf=null a=-45/3 wA=33.0 (same paint, same settings, stale font)=> (-top, bottom) = (46, 3) px = 15.333 / 1.000 dp at density 3, i.e. the line box has the icon
font's shape while ReactTextView draws with the real system font (1.044 / 0.282 / 1.326 em): line
boxes ~24% too short, advance widths too narrow (33 vs 36 px for "A" at 48px) -> clipping.It also explains "not every text is affected": nodes with explicit font attributes take the other
branch of updateTextPaint() and measure correctly. Before the patch a single fontSize produced two
different line boxes on the same screen (phone fs=13: 12.667/1.000/13.667 and 13.667/3.667/17.333;
tablet fs=15: 14.800/1.200/16.000 and 16.000/4.400/20.400). After the patch only the correct one
remains — one bug, not two.Note: the two system fonts involved (MI Lan Pro VF and MiSans VF) have identical hhea metrics
(1.044/0.282/1.326 em) and no MVAR, so font weight/table differences cannot produce a 1.02 <-> 1.33 em
gap.3. Patched-build test you asked for — done on affected hardware
RN 0.78.3 still ships TextLayoutManager as Java on the 0.78 branch, so we backported the #58036 change
to 0.78.3 (paint.setTypeface(null)->paint.setTypeface(getSystemDefaultTypeface(context)), cached
TextView typeface invalidated on Configuration change), recompiled that one file against the published
AAR, swapped the classes back in and repackaged. Control: recompiling the unpatched file reproduces
the published class byte-for-byte (javap -p -cdiff empty), so the recompile is not a variable.Both devices: same app JS (lx-music-mobile 1.9.1), only the react-android classes change, installed
over each other. Native line metrics from onTextLayout (dp: ascender/descender/height):Xiaomi 17 Max (byron), HyperOS, API 37, density 480:
fontSize 0.78.3 unpatched 0.78.3 + patch 0.73.11 old arch 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 (sheng), HyperOS, API 37, density 400 (identical theme font file, md5
1d0a27abd80dcefb1ec64c3934e767bb):fontSize 0.78.3 unpatched 0.78.3 + patch 0.73.11 old arch 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 and the line box from ~1.02 em back
to ~1.36 em; onLayout view heights grow accordingly. No FATAL / NoSuchMethodError / VerifyError.
On a third device that does not reproduce (Xiaomi Pad 4 Plus, AOSP 16, density 320) the patch is a
behavioural no-op, as expected — 15/16/19/23 all unchanged and already correct.Raw logs, the backport diff, the patched classes and the instrumentation source are available; happy
to attach them here or in the PR, and to test a specific commit/build if you would rather validate the
merged version than a 0.78.3 backport.
Reproducer
Minimal reproducer (no bundled font asset required). It relies on the fact that any explicitly set
font family poisons the shared paint, and uses the framework aliascasual(Dancing Script) because
its vertical metrics are far from the default font's on every Android version:device / ROM build R1 <Text fontFamily="casual">R2 plain <Text>R3 plain <Text>result Xiaomi 17 Max, HyperOS 0.78.3 unpatched 49.7 / 23.7 / 73.3 px 49.7 / 23.7 / 73.3 49.7 / 23.7 / 73.3 reproduced Xiaomi 17 Max, HyperOS 0.78.3 + #58036 backport 49.7 / 23.7 / 73.3 50.3 / 13.7 / 64.0 50.3 / 13.7 / 64.0 fixed Xiaomi Pad 4 Plus, stock AOSP 16 0.78.3 unpatched 49.5 / 23.5 / 73.0 45.0 / 14.0 / 59.0 45.0 / 14.0 / 59.0 not affected (fontSize 48; ascender / descender / height in px, from
onTextLayout)One gotcha worth mentioning for anyone trying this: R2/R3 must use different strings from R1. RN
caches text layouts per attributed string, so an identical string silently reuses R1's layout and the
bug looks absent (our first attempt gave a false negative for exactly this reason).RNTesterPlayground.js
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict-local * @format */ /** * Reproducer for react-native#57950 (Android, Fabric / new architecture) * * Fabric measures every <Text> with ONE shared, thread-local TextPaint. For text without an * explicit fontFamily/fontWeight/fontStyle, updateTextPaint() restores the default with * `paint.reset(); paint.setTypeface(null)`. On some OEM ROMs that does not restore the typeface: * `Paint.reset()` keeps the resolved font, and the following `Paint.setTypeface(null)` is a no-op * when the paint's Java-level typeface is already null. The paint therefore keeps the last * typeface that was set *explicitly*, and every following plain <Text> is measured with it while * ReactTextView renders with the real system font -> line boxes too short / glyphs clipped. * * This screen needs no bundled font: any explicit font family poisons the shared paint. It uses the * framework alias "casual" (Dancing Script) because its vertical metrics are far from the default * font's on every Android version, so the two rows are trivially distinguishable. * * Rows R2/R3 use DIFFERENT strings: RN caches text layouts per attributed string, and identical * strings would silently reuse R1's layout and hide the bug. * * Verified on device (fontSize 48, metrics in px, ascender/descender/height): * * Xiaomi 17 Max, HyperOS, unpatched 0.78.3 : R1 49.7/23.7/73.3 R2 49.7/23.7/73.3 R3 49.7/23.7/73.3 -> REPRODUCED * Xiaomi 17 Max, HyperOS, patched 0.78.3: R1 49.7/23.7/73.3 R2 50.3/13.7/64.0 R3 50.3/13.7/64.0 -> fixed * Xiaomi Pad 4 Plus, stock AOSP 16, unpatched: R1 49.5/23.5/73.0 R2 45.0/14.0/59.0 R3 45.0/14.0/59.0 -> not affected */ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import RNTesterText from '../../components/RNTesterText'; import * as React from 'react'; import {StyleSheet, View} from 'react-native'; const FONT_SIZE = 48; const EPSILON = 0.5; type Metrics = {ascender: number, descender: number, height: number}; function useTextMetrics(): [Metrics | null, (e: any) => void] { const [metrics, setMetrics] = React.useState<Metrics | null>(null); const onTextLayout = React.useCallback((e: any) => { const line = e.nativeEvent.lines[0]; setMetrics({ ascender: line.ascender, descender: line.descender, height: line.height, }); }, []); return [metrics, onTextLayout]; } function Sample({ label, text, fontFamily, onMetrics, }: { label: string, text: string, fontFamily?: string, onMetrics?: (m: Metrics) => void, }) { const [metrics, onTextLayout] = useTextMetrics(); React.useEffect(() => { if (metrics != null && onMetrics != null) { onMetrics(metrics); } }, [metrics, onMetrics]); return ( <View style={styles.row}> <RNTesterText style={styles.label}>{label}</RNTesterText> <RNTesterText style={[styles.sample, fontFamily != null ? {fontFamily} : null]} onTextLayout={onTextLayout}> {text} </RNTesterText> <RNTesterText style={styles.metrics}> {metrics == null ? 'measuring...' : `asc ${metrics.ascender.toFixed(1)} / desc ${metrics.descender.toFixed(1)} / h ${metrics.height.toFixed(1)} px`} </RNTesterText> </View> ); } function sameMetrics(a: Metrics | null, b: Metrics | null): boolean { if (a == null || b == null) { return false; } return ( Math.abs(a.ascender - b.ascender) < EPSILON && Math.abs(a.descender - b.descender) < EPSILON && Math.abs(a.height - b.height) < EPSILON ); } function Playground(): React.Node { const [explicit, setExplicit] = React.useState<Metrics | null>(null); const [plain1, setPlain1] = React.useState<Metrics | null>(null); const [plain2, setPlain2] = React.useState<Metrics | null>(null); const done = plain1 != null && plain2 != null && explicit != null; const leaked = done && sameMetrics(plain1, explicit) && sameMetrics(plain2, explicit); return ( <View style={styles.container}> <Sample label='R1 <Text fontFamily="casual">' fontFamily="casual" text="Dazzle" onMetrics={setExplicit} /> <Sample label="R2 plain <Text> (different string)" text="Winter" onMetrics={setPlain1} /> <Sample label="R3 plain <Text> (different string)" text="Rocket" onMetrics={setPlain2} /> <RNTesterText style={[styles.verdict, leaked ? styles.bad : styles.good]}> {!done ? 'measuring...' : leaked ? 'REPRODUCED: the plain rows are measured with the "casual" font metrics, i.e. the shared TextPaint did not restore the typeface.' : 'NOT AFFECTED: the plain rows use the default font metrics, different from the explicit "casual" metrics.'} </RNTesterText> </View> ); } const styles = StyleSheet.create({ container: {padding: 10}, row: {marginBottom: 12}, label: {fontSize: 12}, sample: {fontSize: FONT_SIZE}, metrics: {fontSize: 12, color: '#666'}, verdict: {fontSize: 14, marginTop: 8, fontWeight: 'bold'}, bad: {color: '#c00'}, good: {color: '#080'}, }); export default { title: 'Playground', name: 'playground', description: 'Test out new features and ideas.', render: (): React.Node => <Playground />, } as RNTesterModuleExample;
Happy to open this as a playground PR or a Snack instead if that is the preferred form for the reproducer bot.
Summary
When Android system default font is bolded/replaced (e.g. Xiaomi MiSans Pro Bold / 'full bold' setting), auto-width components are measured with Typeface.DEFAULT while rendering uses the TextView default typeface which inherits the system font configuration. The measured width is therefore too small, and the last glyph can be clipped or missing.
Example:
Englishrenders asEnglison Xiaomi with the system-wide bold font enabled.Environment
MiSans Pro BoldRoot cause
TextLayoutManager.updateTextPaint()resets the paint typeface to null (Typeface.DEFAULT) when no explicit font family/weight/style is set, whileReactTextViewrenders with the TextView default typeface which carries the system bold/replacement config. Different metrics and advances mean measurement is narrower than what is actually drawn.On device, same 48px text:
Typeface.DEFAULT)English(7 glyphs): measured width 53.33dp (only 6 glyphs laid out), drawn width 56.67dp (7 glyphs). The finalhis dropped.Suggested fix
Make the measurement typeface the same source as rendering when no explicit font family/weight/style is set. A local patch that uses the system/default TextView typeface for measurement fixes the issue and was verified on 5 Android devices.
Additional context
This analysis and the local fix were produced with the assistance of an AI coding agent (Codex). Please review independently.
Related