Repository navigation
Text visually truncated mid-word despite correct onLayout/onTextLayout measurement #57920
Description
Activity
Root cause found, and it is not in the text layer at all — it is in Yoga's pixel-grid rounding.
Fix submitted upstream: react/yoga#2011
What happens
roundLayoutResultsToPixelGrid()derives a node's height as the difference of two independently
rounded absolute edges:setDimension(Dimension::Height, roundValueToPixelGrid(absoluteNodeBottom, ...) - roundValueToPixelGrid(absoluteNodeTop, ...));
roundValueToPixelGrid()divides back bypointScaleFactorand narrows tofloatbefore returning,
so the subtraction operates on two values that each already carry a representation error. The
committed height ends up a fraction of a point below the measured one.iOS TextKit applies a strict "does this line fit in the remaining height" test, so that sub-point
shortfall makes it drop the entire trailing line rather than absorb the rounding — which is
exactly the reported symptom: a correct, complete measurement, and an incomplete paint.It explains both numbers in the original report
The error is one float ULP at the magnitude of the node's absolute Y coordinate, and a float's ULP
grows with magnitude:reported shortfall = absolute Y implied 288→287.9997558593750.0002441406252⁻¹² 2048–4096 480→479.999938964843750.000061035156252⁻¹⁴ 512–1024 Two different paragraphs, two different shortfalls, each exactly the float ULP at its own scroll
position. That is also why it looked position-dependent and content-independent: the error is a
function of where the paragraph sits, not of what it says.Yoga actually tries to prevent this — the code forces ceil/floor on the edges of nodes with a measure
function, with the comment "we never want to round down its size as this could lead to unwanted text
truncation". Each edge is rounded in the right direction; the subtraction of the two narrowed
operands is not.It should only affect 3x screens
n/2is dyadic and always exactly representable infloat, so 2x devices never produce the error.
n/3almost never is. Sweeping 40000 grid-aligned positions: 0 divergences at 2x, 2816 at 3x.If you are hitting this, checking whether it is 3x-only on your side would be a useful confirmation —
and if anyone reproduces it on a 2x device, that would mean a second mechanism is involved.Verification
Measured on the repro (iPhone 17 simulator, 20 fresh launches per arm, identical instrumentation,
the Yoga change the only difference):before after committed height 287.999755859375288.000000000000line fragments drawn 11 / 12 12 / 12 truncated renders 19 0 The upstream PR adds a regression test that fails on
mainand passes with the change; the full Yoga
suite passes (843/843).Notes
- Android: the root cause is in Yoga, which is shared, so Android is likely affected by the same
arithmetic. The downstream text engine differs (StaticLayout/Skia rather than TextKit) and its
tolerance to a sub-pixel shortfall has not been tested, so this is unconfirmed. - Workaround in the meantime: snapping the container height up to the pixel grid in
RCTTextLayoutManager.mm(RCTCeilPixelValue) before building theNSTextContainerabsorbs the
shortfall at the TextKit boundary. It is a no-op once the Yoga fix lands.
- Android: the root cause is in Yoga, which is shared, so Android is likely affected by the same
The
479.99993896484375instead of480in youronLayoutoutput is worth calling out: that is the float32 signature of Yoga's pixel-grid rounding, and it is the same defect I have just put up a width fix for in react-native#58270 (Yoga side: react/yoga#2016).roundLayoutResultsToPixelGridrounds a node's leading and trailing edge to the physical pixel grid independently and takes the size as the difference. A value within0.0001of a whole pixel is treated as already on the grid, and the two edges can fall on opposite sides of that tolerance — one gets snapped up, the other floored — leaving the node one physical pixel smaller than it measured. A measured size that arrives as479.99993896484375is exactly the kind of value that trips it.To be clear about scope: my PR only changes the width dimension and would not fix this report. The height dimension has the same asymmetry in the same function, but I left it alone because a height shortfall clips a row of pixels rather than causing the text to be re-broken at the mounted size, and I had no device evidence for it. Your report is the first concrete case I have seen for the height side, so it is useful evidence that the height dimension is worth fixing too — I have referenced it on the PR.
Two things that would help confirm it:
- What is
PixelRatio.get()on the affected device? The bug requires a non-integer value; 2 is immune because 1/2 is exactly representable in binary, whereas 2.75 and 3 are not. - Does nudging a fractional
margin/paddingon an ancestor by a hair make it appear or disappear? That in/out behaviour is the tell for a rounding coincidence rather than a measurement bug, and would explain why it depends on list position and survives a remount.
- What is
Same defect here on 0.86.3 (iOS, Fabric), with numbers that show the frame side of it, which fits the Yoga rounding analysis above.
A paragraph measured at 10 lines × 21pt = 210.0.
onLayoutreports its frame as209.9998779296875.onTextLayoutat that frame returns 9 lines, and the 9th line holds all of the remaining text at exactly the frame width (287). So TextKit drops the 10th line, andNSLineBreakByClippingputs everything left onto the last line, clipped at the right edge. That is the "truncated mid-word" symptom, plus a line of empty space at the bottom.It reproduces in plain TextKit with no React Native involved. The same attributed string (14pt system font, min/max line height 21, the baseline offset from
RCTApplyBaselineOffset,lineFragmentPadding = 0,usesFontLeading = NO,NSLineBreakByClipping) lays out 9 lines in a 287 × 209.9998779296875 container, and 10 lines at 287 × 210.0. It depends on the view's absolute position, so it sticks to one scroll position and survives the text changing (for example, regenerating a message).#57698 (merged Jul 27) looks like it covers this from the measurement side, since it adds slack before ceiling text measurements to the pixel grid. It isn't in 0.86.3. Could it be cherry-picked into 0.86?
Workaround until then: we add 0.01 to
lineHeighton iOS. That keeps the measured height off the pixel grid, so ceiling it leaves enough room for the float error. In a TextKit simulation it removed the clipping for every line count from 1 to 60 at font sizes 12–18, with float shortfalls up to 0.002pt.- added a commit that references this issue
on Sep 26, 2026 Same bug here on RN 0.86.3 (Expo SDK 57), iPhone 17e simulator (3x), at the largest accessibility text size. A two-line
Text(48pt lines) committed a height of95.9998779against a measured96, a shortfall of2⁻¹³, which is the float ULP for an absolute Y between 1024 and 2048pt. The same row at y=87.667 got exactly96. TextKit drew one line and dropped the second. Appending'\n'to the text fixes it for us, though we haven't tried theNSTextContainerpad from the workaround above.
Description
A multi-line
<Text>inside a list (FlatList,@shopify/flash-list, oreven a plain
ScrollView) is sometimes visually truncated mid-word,even though
onLayout/onTextLayoutreport a correct and completemeasurement for the same text.
Evidence gathered from a minimal, isolated repro:
onLayoutsometimes reports a height that doesn't matchonTextLayout'sown measured content — either a tiny fractional value (e.g.
479.99993896484375instead of exactly480), or a value roughly onewhole line taller than what
onTextLayoutitself measured as needed(e.g. container
288/12 lines vs text measured at264/11 lines).onTextLayout, for the exact same text and style, can report adifferent
lines.lengthbetween two mounts, with the container widthstrictly constant.
onTextLayoutreporting the correct, complete text (includingits exact final characters), the visual render sometimes stops mid-word,
with blank space left where the missing text should be — a divergence
between a correct measure pass and an incomplete native paint pass.
This was first found in a production app and then isolated down to a
minimal case. The following were tested and ruled out as the cause:
@shopify/flash-list, coreFlatList, and a plainScrollView.fontFamilyset at all).no quotation marks, periods only.
onLayoutsometimes reserves moreheight than
onTextLayoutmeasured as necessary, and the text is stillvisually truncated.
keychange): the bug survives a full remount.repeated paragraph textually unique did not spread the bug to more
occurrences.
The bug is intermittent and appears to depend on rendering context
(surrounding items, timing) rather than the text content or any specific
component — the exact same paragraph text reproduces at one list position
and not another, and moving it to a different position moves the
reproduction with it.
Steps to reproduce
npm install && npx expo run:iosconsole for
[REPRO] onLayout ... <<< SUSPECTlog lines and look at thescreen for a paragraph whose text stops mid-word with blank space below
it.
(Cmd+R in the simulator) — it's intermittent. A "Scroll to bottom"
button is provided to check paragraphs further down the list.
Full isolation-testing notes and commit-by-commit findings are in the
repo's README.
React Native Version
0.86.2
Affected Platforms
Runtime - iOS, Android
This minimal repro was tested on iOS (simulator). The underlying text
truncation was also observed on Android (16 / API 36) with the same
fontSize/lineHeightin the production app this repro was extractedfrom, though not exhaustively isolated there.
Environment
(
npx react-native infodoesn't run standalone in this Expo-managed repro;the versions above are read directly from
package.json.)Stacktrace or Logs
onTextLayoutmeasured 11 lines (264px, correct, complete text) but thecontainer (
onLayout) reserved 288px (12 lines) — a full line more thanwhat was measured as necessary — and the paragraph is visually cut off
mid-word regardless.
Reproducer
https://github.com/Ge0ffreyS/rn-font-issue