Skip to content

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

Description

@pangziqiang

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: English renders as Englis on Xiaomi with the system-wide bold font enabled.

Environment

  • React Native 0.78.3 (Fabric / Bridgeless)
  • Android 16 (API 36), Xiaomi HyperOS with system font set to MiSans Pro Bold
  • Reproduced on multiple Xiaomi devices

Root cause

TextLayoutManager.updateTextPaint() resets the paint typeface to null (Typeface.DEFAULT) when no explicit font family/weight/style is set, while ReactTextView renders 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:

ascent descent line height
measurement paint (Typeface.DEFAULT) -45 3 1.0em
rendering TextView (system bold) -50 14 1.33em

English (7 glyphs): measured width 53.33dp (only 6 glyphs laid out), drawn width 56.67dp (7 glyphs). The final h is 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

Activity

  1. react-native-bot commented on Aug 13, 2026

    @react-native-bot
    Collaborator

    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:


    You can read more about about it on our website: How to report a bug.

  2. pangziqiang commented on Aug 13, 2026

    @pangziqiang
    Author
  3. sbaiahmed1 commented on Aug 19, 2026

    @sbaiahmed1
    Contributor

    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-wide Typeface.DEFAULT, so a measurement TextPaint with 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 TextView uses) touches shared text measurement for all Android users, it really needs validation on affected hardware. If you can test a patched TextLayoutManager on your Xiaomi devices, I'd be happy to collaborate on a PR — the change itself is small; the risk is entirely in verification.

  4. pangziqiang commented on Oct 1, 2026

    @pangziqiang
    Author

    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() does paint.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, wA 36.0 asc -45, desc 14, top -45, bottom 14, wA 30.0
    R1 setTypeface(<bundled icon font>) asc -45, desc 3, top -46, bottom 3, wA 33.0 asc -45, desc 3, top -46, bottom 3, wA 29.0
    R2 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) -> reset clean clean

    So Paint.reset() never restores the typeface (R2 on both) and the following setTypeface(null)
    is a no-op on HyperOS (R3) while it works on AOSP. That is the same divergence your stock emulator
    showed with font_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 -c diff 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 alias casual (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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: AttentionIssues where the author has responded to feedback.Needs: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions