Skip to content

Text visually truncated mid-word despite correct onLayout/onTextLayout measurement #57920

Description

@Ge0ffreyS

Description

A multi-line <Text> inside a list (FlatList, @shopify/flash-list, or
even a plain ScrollView) is sometimes visually truncated mid-word,
even though onLayout / onTextLayout report a correct and complete
measurement for the same text.

Evidence gathered from a minimal, isolated repro:

  • onLayout sometimes reports a height that doesn't match onTextLayout's
    own measured content — either a tiny fractional value (e.g.
    479.99993896484375 instead of exactly 480), or a value roughly one
    whole line taller than what onTextLayout itself measured as needed
    (e.g. container 288/12 lines vs text measured at 264/11 lines).
  • onTextLayout, for the exact same text and style, can report a
    different lines.length between two mounts, with the container width
    strictly constant.
  • Despite onTextLayout reporting the correct, complete text (including
    its 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:

  • Any specific list component: reproduces identically with
    @shopify/flash-list, core FlatList, and a plain ScrollView.
  • Custom fonts: reproduces identically with the system font (no
    fontFamily set at all).
  • Special characters: reproduces with plain ASCII English text, no accents,
    no quotation marks, periods only.
  • Insufficient container height: onLayout sometimes reserves more
    height than onTextLayout measured as necessary, and the text is still
    visually truncated.
  • A component remount (key change): the bug survives a full remount.
  • A text-measurement cache keyed by content: making every occurrence of a
    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

  1. Clone the reproducer: https://github.com/Ge0ffreyS/rn-font-issue
  2. npm install && npx expo run:ios
  3. The app renders ~150 numbered paragraphs on load. Watch the Metro
    console for [REPRO] onLayout ... <<< SUSPECT log lines and look at the
    screen for a paragraph whose text stops mid-word with blank space below
    it.
  4. If it doesn't reproduce on first load, reload the app a few times
    (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/lineHeight in the production app this repro was extracted
from, though not exhaustively isolated there.

Environment

OS: macOS 26.6.1
Node: 22.23.2
expo: ~57.0.12
react: 19.2.3
react-native: 0.86.2

(npx react-native info doesn't run standalone in this Expo-managed repro;
the versions above are read directly from package.json.)

Stacktrace or Logs

[REPRO] onTextLayout lineCount=11 height=264 lastLineText="...This is repeat number 1." text="18. A former head chef says th..."
[REPRO] onLayout height=287.999755859375 text="18. A former head chef says th..."  <<< SUSPECT

onTextLayout measured 11 lines (264px, correct, complete text) but the
container (onLayout) reserved 288px (12 lines) — a full line more than
what was measured as necessary — and the paragraph is visually cut off
mid-word regardless.

Reproducer

https://github.com/Ge0ffreyS/rn-font-issue

Activity

  1. Ge0ffreyS commented on Aug 26, 2026

    @Ge0ffreyS
    Author

    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 by pointScaleFactor and narrows to float before 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.999755859375 0.000244140625 2⁻¹² 2048–4096
    480 → 479.99993896484375 0.00006103515625 2⁻¹⁴ 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/2 is dyadic and always exactly representable in float, so 2x devices never produce the error.
    n/3 almost 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.999755859375 288.000000000000
    line fragments drawn 11 / 12 12 / 12
    truncated renders 19 0

    The upstream PR adds a regression test that fails on main and 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 the NSTextContainer absorbs the
      shortfall at the TextKit boundary. It is a no-op once the Yoga fix lands.
  2. SireAI commented on Sep 1, 2026

    @SireAI

    The 479.99993896484375 instead of 480 in your onLayout output 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).

    roundLayoutResultsToPixelGrid rounds a node's leading and trailing edge to the physical pixel grid independently and takes the size as the difference. A value within 0.0001 of 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 as 479.99993896484375 is 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/padding on 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.
  3. bamtheboozle commented on Sep 25, 2026

    @bamtheboozle

    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. onLayout reports its frame as 209.9998779296875. onTextLayout at 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, and NSLineBreakByClipping puts 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 lineHeight on 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.

  4. wsquared commented on Sep 28, 2026

    @wsquared

    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 of 95.9998779 against a measured 96, a shortfall of 2⁻¹³, which is the float ULP for an absolute Y between 1024 and 2048pt. The same row at y=87.667 got exactly 96. TextKit drew one line and dropped the second. Appending '\n​' to the text fixes it for us, though we haven't tried the NSTextContainer pad from the workaround above.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions