Skip to content

Commit af1c3f8

Browse files
committed
chore(rn-tester): add Playground reproducer for Android Fabric shared TextPaint typeface leak
1 parent b2cd5ea commit af1c3f8

1 file changed

Lines changed: 113 additions & 6 deletions

File tree

‎packages/rn-tester/js/examples/Playground/RNTesterPlayground.js‎

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,133 @@
88
* @format
99
*/
1010

11+
/**
12+
* Reproducer for react-native#57950 (Android, Fabric / new architecture)
13+
*
14+
* Fabric measures every <Text> with ONE shared, thread-local TextPaint. For text without an
15+
* explicit fontFamily/fontWeight/fontStyle, updateTextPaint() restores the default with
16+
* `paint.reset(); paint.setTypeface(null)`. On some OEM ROMs that does not restore the typeface:
17+
* `Paint.reset()` keeps the resolved font, and the following `Paint.setTypeface(null)` is a no-op
18+
* when the paint's Java-level typeface is already null. The paint therefore keeps the last
19+
* typeface that was set *explicitly*, and every following plain <Text> is measured with it while
20+
* ReactTextView renders with the real system font -> line boxes too short / glyphs clipped.
21+
*
22+
* This screen needs no bundled font: any explicit font family poisons the shared paint. It uses the
23+
* framework alias "casual" (Dancing Script) because its vertical metrics are far from the default
24+
* font's on every Android version, so the two rows are trivially distinguishable.
25+
*
26+
* Rows R2/R3 use DIFFERENT strings: RN caches text layouts per attributed string, and identical
27+
* strings would silently reuse R1's layout and hide the bug.
28+
*
29+
* Verified on device (fontSize 48, metrics in px, ascender/descender/height):
30+
*
31+
* 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
32+
* 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
33+
* 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
34+
*/
35+
1136
import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
1237

1338
import RNTesterText from '../../components/RNTesterText';
1439
import * as React from 'react';
1540
import {StyleSheet, View} from 'react-native';
1641

17-
function Playground() {
42+
const FONT_SIZE = 48;
43+
const EPSILON = 0.5;
44+
45+
type Metrics = {ascender: number, descender: number, height: number};
46+
47+
function useTextMetrics(): [Metrics | null, (e: any) => void] {
48+
const [metrics, setMetrics] = React.useState<Metrics | null>(null);
49+
const onTextLayout = React.useCallback((e: any) => {
50+
const line = e.nativeEvent.lines[0];
51+
setMetrics({
52+
ascender: line.ascender,
53+
descender: line.descender,
54+
height: line.height,
55+
});
56+
}, []);
57+
return [metrics, onTextLayout];
58+
}
59+
60+
function Sample({
61+
label,
62+
text,
63+
fontFamily,
64+
onMetrics,
65+
}: {
66+
label: string,
67+
text: string,
68+
fontFamily?: string,
69+
onMetrics?: (m: Metrics) => void,
70+
}) {
71+
const [metrics, onTextLayout] = useTextMetrics();
72+
React.useEffect(() => {
73+
if (metrics != null && onMetrics != null) {
74+
onMetrics(metrics);
75+
}
76+
}, [metrics, onMetrics]);
77+
return (
78+
<View style={styles.row}>
79+
<RNTesterText style={styles.label}>{label}</RNTesterText>
80+
<RNTesterText
81+
style={[styles.sample, fontFamily != null ? {fontFamily} : null]}
82+
onTextLayout={onTextLayout}>
83+
{text}
84+
</RNTesterText>
85+
<RNTesterText style={styles.metrics}>
86+
{metrics == null
87+
? 'measuring...'
88+
: `asc ${metrics.ascender.toFixed(1)} / desc ${metrics.descender.toFixed(1)} / h ${metrics.height.toFixed(1)} px`}
89+
</RNTesterText>
90+
</View>
91+
);
92+
}
93+
94+
function sameMetrics(a: Metrics | null, b: Metrics | null): boolean {
95+
if (a == null || b == null) {
96+
return false;
97+
}
98+
return (
99+
Math.abs(a.ascender - b.ascender) < EPSILON &&
100+
Math.abs(a.descender - b.descender) < EPSILON &&
101+
Math.abs(a.height - b.height) < EPSILON
102+
);
103+
}
104+
105+
function Playground(): React.Node {
106+
const [explicit, setExplicit] = React.useState<Metrics | null>(null);
107+
const [plain1, setPlain1] = React.useState<Metrics | null>(null);
108+
const [plain2, setPlain2] = React.useState<Metrics | null>(null);
109+
110+
const done = plain1 != null && plain2 != null && explicit != null;
111+
const leaked = done && sameMetrics(plain1, explicit) && sameMetrics(plain2, explicit);
112+
18113
return (
19114
<View style={styles.container}>
20-
<RNTesterText>
21-
Edit "RNTesterPlayground.js" to change this file
115+
<Sample label='R1 <Text fontFamily="casual">' fontFamily="casual" text="Dazzle" onMetrics={setExplicit} />
116+
<Sample label="R2 plain <Text> (different string)" text="Winter" onMetrics={setPlain1} />
117+
<Sample label="R3 plain <Text> (different string)" text="Rocket" onMetrics={setPlain2} />
118+
<RNTesterText style={[styles.verdict, leaked ? styles.bad : styles.good]}>
119+
{!done
120+
? 'measuring...'
121+
: leaked
122+
? 'REPRODUCED: the plain rows are measured with the "casual" font metrics, i.e. the shared TextPaint did not restore the typeface.'
123+
: 'NOT AFFECTED: the plain rows use the default font metrics, different from the explicit "casual" metrics.'}
22124
</RNTesterText>
23125
</View>
24126
);
25127
}
26128

27129
const styles = StyleSheet.create({
28-
container: {
29-
padding: 10,
30-
},
130+
container: {padding: 10},
131+
row: {marginBottom: 12},
132+
label: {fontSize: 12},
133+
sample: {fontSize: FONT_SIZE},
134+
metrics: {fontSize: 12, color: '#666'},
135+
verdict: {fontSize: 14, marginTop: 8, fontWeight: 'bold'},
136+
bad: {color: '#c00'},
137+
good: {color: '#080'},
31138
});
32139

33140
export default {

0 commit comments

Comments
 (0)