In a list, the text on the 10th row alone is tiny. Reorder the data and it is still the 10th row. Restart the app and nothing changes. Put the same string on row 9 and it renders at a normal size.
At that point most of us suspect the data, then the string, then the OS version, and half a day is gone. I learned about this symptom from a write-up shared on Zenn (異常に小さい文字サイズを追いかけたら、丸め誤差問題だった話, in Japanese). This post is my record of reading the public React Native and Yoga sources and porting the relevant arithmetic to Node to see how it behaves.
One caveat first: I did not reproduce it on a device. What I checked is what the source says, and how often that arithmetic comes up short in float32. On top of that I have put together an order of suspicion and a few fixes.
Three quick checks before you blame the string
Do these in order. Each takes a few minutes.
- Move the same string to another row. If row 9 is fine and row 10 shrinks, the cause is position, not content.
- Print the
onLayoutheight to six decimals. Rounded output hides it as11. UsetoFixed(6). - Compare the box height with
lineHeight. A 22pt box holding two lines oflineHeight: 11is the typical shape.
If step 2 shows something like 10.999969, a hair under what you expect, this article applies. If not, look elsewhere: an enlarged system text size, Fabric view recycling, and so on.
Why the box ends up a hair too short
Yoga snaps layout results to the pixel grid. In PixelGrid.cpp, a node's height is computed from three pieces:
- the bottom edge's absolute position, rounded to the grid (a
float) - the top edge's absolute position, rounded to the grid (a
float) - the difference between the two
So the height is not rounded directly. The top and bottom are each reduced to float32 and then subtracted. The larger the absolute coordinate, the coarser float32 gets: the step is 2^-15 (about 0.00003pt) between 256 and 512pt, and 2^-14 between 512 and 1024pt.
Text nodes have a safeguard (forceCeil / forceFloor) meant to avoid rounding the size down. Even so, nothing guarantees that the subtraction lands exactly on the original height.
Now the deciding side. On iOS, adjustsFontSizeToFit is handled by NSTextStorage+FontScaling.m, which decides whether text fits like this:
BOOL fitsSize = size.width >= measuredSize.width && size.height >= measuredSize.height;There is no tolerance. A box of 10.99997 against a used height of 11 means "does not fit." The search also changes only the font size; nothing in the file touches the paragraph's lineHeight. With a fixed lineHeight of 11, the used height stays 11 at any scale. The search never records a fit, falls back to the initial lastRatioWhichFits of 0.02, and lands on the minimum size.
Three things must line up:
| Step | What happens | Where to check |
|---|---|---|
| 1 | The row's box height comes out one float32 step below the expected value | onLayout height at six decimals |
| 2 | The used height fails a no-tolerance comparison and counts as "does not fit" | Is the box height equal to lineHeight × lines? |
| 3 | Shrinking never changes lineHeight, so nothing ever fits | Is lineHeight fixed? |
Remove any one step and the symptom disappears. That is what decides the fix.
Counting it: which screens, and how often
If position decides the outcome, moving the position should tell us the frequency. I ported the rounding in PixelGrid.cpp to JavaScript, used Math.fround for float32, and slid the top edge across 0 to 900pt. Save this as probe.mjs and run node probe.mjs.
// probe.mjs — a float32 port of Yoga's roundLayoutResultsToPixelGrid for a Text node's height
// Based on the formulas in PixelGrid.cpp (facebook/react-native, main). Not a device reproduction.
const inexactEquals = (a, b) => Math.abs(a - b) < 0.0001;
function roundValue(value, scale, forceCeil, forceFloor) {
let s = value * scale;
let frac = s % 1;
if (frac < 0) frac += 1;
if (inexactEquals(frac, 0)) s -= frac;
else if (inexactEquals(frac, 1)) s = s - frac + 1;
else if (forceCeil) s = s - frac + 1;
else if (forceFloor) s -= frac;
else s = s - frac + (frac >= 0.5 ? 1 : 0);
return Math.fround(s / scale); // the (float) cast on the C++ side
}
function textHeight(top, h, scale) {
const fractional = !inexactEquals(Math.round(h * scale), h * scale);
const bottom = roundValue(top + h, scale, fractional, !fractional);
const topRounded = roundValue(top, scale, false, true);
return Math.fround(bottom - topRounded); // float minus float
}
const heights = [11, 14, 16, 18, 20, 22, 24, 33];
for (const scale of [2, 3]) {
for (const h of heights) {
let n = 0, short = 0, worst = 0;
for (let k = 0; k < 5400; k++) { // 0–900pt in 1/6pt steps
const top = k / 6;
n++;
const r = textHeight(top, h, scale);
if (r < h) { short++; worst = Math.max(worst, h - r); }
}
console.log(`scale=${scale} height=${h} positions=${n} short=${short} worst=${worst.toExponential(2)}`);
}
}Here is what it printed (2,700 top positions per row of the table, from 0 to 900pt in 1/6pt steps, grouped by display scale):
| Display scale | Box height (pt) | Positions where the box is short | Largest shortfall |
|---|---|---|---|
| 2x | all eight from 11 to 33 | 0 in every case | 0 |
| 3x | 11 | 104 (about 3.9%) | about 1.5e-5 pt |
| 3x | 22 | 164 (about 6.1%) | about 1.5e-5 pt |
| 3x | 33 | 196 (about 7.3%) | about 1.5e-5 pt |
Two things stand out.
First, the display scale changes everything. At 2x nothing came up short; at 3x, 4 to 7 percent of positions did. 3x is the standard scale on current iPhones, including the iPhone 13 and 17 Pro. If your simulator or test device is 2x, you can ship this without ever seeing it. As an indie developer I mostly test on one or two devices, so a bug that depends on display scale is exactly the kind I could miss.
Second, 4 to 7 percent is a lot. In a list with many rows, some row will hit it, and because the row is determined by absolute position, it is the same row every time for the same layout. That fits the reports: same row after a restart, gone after moving the component to another screen.
My largest shortfall, about 1.5e-5pt, does not match the 2^-15 (about 3.1e-5) in the shared report. I place the top edge at exact 1/6pt values, while a real screen accumulates parent positions in float32, so the conditions differ a little. What matters is not the exact amount but that shortfalls above zero exist at real positions.
Three fixes, and how to choose
Option 1: give the box a tiny amount of slack
If the check is size.height >= measuredSize.height, a box slightly taller than the used height will survive a one-step float32 error. Running the same scan with slack added:
| Slack | Positions scanned (2x and 3x, eight heights) | Boxes shorter than the content |
|---|---|---|
| 0pt | 43,200 | 1,216 |
| 0.01pt | 43,200 | 0 |
| 0.05pt and up (0.25 and 0.5 also checked) | 43,200 | 0 |
I would pick 0.1pt. At 3x it is about 0.3 of a physical pixel, and Yoga rounds fractional text heights up, so it should not be visible. The float32 step near 1,000pt is about 0.00006pt, which leaves a margin of more than 1,000 times.
// fit-slack.ts — size the box as "used height + slack"
// Purpose: keep adjustsFontSizeToFit from collapsing on its no-tolerance comparison
const FIT_SLACK_PT = 0.1;
export function fitBoxHeight(lineHeight: number, lines: number): number {
return lineHeight * lines + FIT_SLACK_PT;
}// PlayerName.tsx
import { Text } from 'react-native';
import { fitBoxHeight } from './fit-slack';
const LINE_HEIGHT = 11;
export function PlayerName({ name }: { name: string }) {
return (
<Text
numberOfLines={2}
adjustsFontSizeToFit
minimumFontScale={0.7}
style={{
fontSize: 10,
lineHeight: LINE_HEIGHT,
height: fitBoxHeight(LINE_HEIGHT, 2),
}}
>
{name}
</Text>
);
}Why it is written this way: the slack goes on height because the box size is what the check uses. Shrinking lineHeight would also work, but it changes line spacing. Widening the box leaves the look alone.
This fix comes from reading the comparison and from the arithmetic; I have not tried it on a device. If you adopt it, confirm on a 3x device that the row that used to shrink no longer does.
Option 2: drop adjustsFontSizeToFit and size by character count
This is what the shared report chose. It bypasses the fit check entirely, so rounding error cannot reach it. The cost is that it ignores glyph width, so fit is rougher for names heavy on narrow or wide characters. It is a sound choice when the symptom is hard to reproduce and you cannot spare time to verify slack on a device.
Option 3: let numberOfLines truncate
If an ellipsis is acceptable, remove adjustsFontSizeToFit and you are done. It suits display names, where a clipped end still makes sense. It does not suit player or product names that must be read to the end.
When unsure, I would choose in this order: Option 1 if you want to keep shrinking, Option 2 if you cannot verify on a device, Option 3 if truncation is enough.
Exceptions and caveats
How this looks depends on your React Native version. In the main branch version of RCTTextLayoutManager.mm that I checked, a given minimumFontScale sets the floor to MAX(minimumFontScale × maximum size, 4.0), so a collapsed row stops at that fraction. The shared report saw 4pt, so your version may read minimumFontScale or not, and the severity differs accordingly. Check the file of the same name under node_modules/react-native.
Also, the case where everyone shrinks after the user raises the system text size has a different cause: the enlarged lineHeight no longer fits the box. Step 3 of the mechanism is shared, but the fix is to make the box follow the font size. That area is covered in Production-Quality VoiceOver and Dynamic Type for Rork Apps.
What to do next
Start by printing onLayout heights with toFixed(6) on the screen where a row shrinks. A small dev-only wrapper like this one makes it harder to ship the problem unnoticed:
// ProbedText.tsx — dev only: warn when a Text box is shorter than expected
import { useCallback } from 'react';
import { Text, type LayoutChangeEvent, type TextProps } from 'react-native';
type Props = TextProps & { expectedHeight: number; label: string };
export function ProbedText({ expectedHeight, label, onLayout, ...rest }: Props) {
const handleLayout = useCallback(
(e: LayoutChangeEvent) => {
if (__DEV__) {
const h = e.nativeEvent.layout.height;
const gap = expectedHeight - h;
if (gap > 0) {
console.warn(`[fit-probe] ${label} height=${h.toFixed(6)} short by ${gap.toExponential(2)}pt`);
}
}
onLayout?.(e);
},
[expectedHeight, label, onLayout],
);
return <Text {...rest} onLayout={handleLayout} />;
}To check actual rendered text rather than the box, onTextLayout gives per-line measurements; the approach is described in Should You Show a Read More Link? Let the Rendered Text Decide in Rork (Expo). Since most current test devices are 3x, it is also worth revisiting which scales your own test devices cover, using the size table in Adapting Your Rork iOS App to iPhone Air and 17 Pro Series — Layout Patterns for 2026's New Resolutions.
My thanks to the person who shared the original case. A symptom this hard to reproduce is far easier to meet the second time when someone has written down the cause in words and numbers.