◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/App Dev
◇ App Dev/2026-10-15Intermediate

When adjustsFontSizeToFit Shrinks Just One Row, Suspect the Box Height, Not the String

Only the 10th row of a list drops to 4pt on iOS, though the data and strings are fine. I read the Yoga rounding and font-fit sources, then counted how often the box comes up short in Node.

React Native239Expo218adjustsFontSizeToFitYogaiOS116layouttroubleshooting69

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.

  1. Move the same string to another row. If row 9 is fine and row 10 shrinks, the cause is position, not content.
  2. Print the onLayout height to six decimals. Rounded output hides it as 11. Use toFixed(6).
  3. Compare the box height with lineHeight. A 22pt box holding two lines of lineHeight: 11 is 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:

StepWhat happensWhere to check
1The row's box height comes out one float32 step below the expected valueonLayout height at six decimals
2The used height fails a no-tolerance comparison and counts as "does not fit"Is the box height equal to lineHeight × lines?
3Shrinking never changes lineHeight, so nothing ever fitsIs 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 scaleBox height (pt)Positions where the box is shortLargest shortfall
2xall eight from 11 to 330 in every case0
3x11104 (about 3.9%)about 1.5e-5 pt
3x22164 (about 6.1%)about 1.5e-5 pt
3x33196 (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:

SlackPositions scanned (2x and 3x, eight heights)Boxes shorter than the content
0pt43,2001,216
0.01pt43,2000
0.05pt and up (0.25 and 0.5 also checked)43,2000

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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◇ App Dev2026-10-14
App Review Says "Guideline 2.1, Blank Screen": Three Places to Look in an Expo Release Build
It opens fine in TestFlight, yet review rejects it for a blank screen. Here is the order I check things in an Expo + EAS release build: which environment holds your variables, secret visibility during config resolution, and what the reviewer's device does differently.
◇ App Dev2026-08-25
Put Your Live Rork App on a Spare iPhone Before iOS 27 Ships
iOS 27 arrives in September and developer beta 7 is already out. Here is how to turn an old iPhone into a beta device, what to check first, and which fixes you can ship from JavaScript without waiting for review.
◇ App Dev2026-08-16
My chart broke on day one, not at scale
A line chart that vanished for anyone with only a few days of data. The cause was a zero-height Y axis turning coordinates into NaN. Here is the measured behavior and the small normalization layer that fixed it.