Skip to content

Subtract rounded edges in pixel space when rounding layout results - #2011

Open
Ge0ffreyS wants to merge 1 commit into
react:mainfrom
Ge0ffreyS:fix/pixel-grid-subtract-in-pixel-space
Open

Subtract rounded edges in pixel space when rounding layout results#2011
Ge0ffreyS wants to merge 1 commit into
react:mainfrom
Ge0ffreyS:fix/pixel-grid-subtract-in-pixel-space

Conversation

@Ge0ffreyS

Copy link
Copy Markdown

Subtract rounded edges in pixel space when rounding layout results

Fixes react/react-native#57920

Problem

roundLayoutResultsToPixelGrid() derives a node's width and height as the difference of two
independently rounded absolute edges:

node->getLayout().setDimension(
    Dimension::Height,
    roundValueToPixelGrid(absoluteNodeBottom, ...) -
        roundValueToPixelGrid(absoluteNodeTop, ...));

roundValueToPixelGrid() divides the rounded value back by pointScaleFactor and narrows the
result to float before returning. The subtraction therefore operates on two values that each
already carry a representation error, and that error leaks into the resulting dimension.

Two conditions have to line up for it to be visible:

  1. pointScaleFactor makes the division inexact. n/2 is dyadic and always exactly
    representable, so a 2x screen never produces an error. n/3 almost never is.
  2. The absolute coordinates are large enough for one float ULP to matter. The error scales with
    the magnitude of the operands, so it appears for nodes far down a long scrolling container.

Sweeping 1001 grid-aligned offsets around 3800pt with pointScaleFactor 3, a node measured at
exactly 288.0 is committed 287.999755859375 at 192 of them — one float ULP short at that
magnitude.

This defeats a guarantee the function makes deliberately, and which its own comment already states:

If a node has a custom measure function we never want to round down its size as this could lead to
unwanted text truncation.

That is why textRounding forces ceil on one edge and floor on the other. Each edge is rounded in
the intended direction — but the subtraction of the two narrowed operands is not.

Why it matters

The consequence is visible in React Native, and it is what led me here.

A multi-line <Text> far down a long list commits a height a fraction of a point below what it
measured. 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 — the
text renders visually truncated, with blank space left in its place, while onLayout and
onTextLayout both report the complete measurement.

The symptoms reported against React Native match the arithmetic exactly: it reproduces on 3x screens
only, it tracks scroll position rather than text content, it is independent of font and of special
characters, and it survives remounting.

Measured on a minimal repro (iPhone 17 simulator, 20 fresh launches per arm, identical
instrumentation, this change the only difference):

before after
committed height 287.999755859375 288.000000000000
line fragments drawn 11 / 12 12 / 12
truncated renders 19 0
draw events analysed 722 724

Fix

Round in pixel space, where a grid-aligned value is an exact integer, so the subtraction is exact and
a single narrowing happens at the end.

  • roundValueToPixelGridScaled() returns the rounded value still scaled by pointScaleFactor.
  • pixelGridValueToPoints() converts back to points, once.
  • The public roundValueToPixelGrid() keeps its exact signature and behaviour as a thin wrapper,
    so YGRoundValueToPixelGrid() and the Cache.cpp callers are untouched.

Test

YGRoundingMeasureFuncTest.rounding_measured_size_is_never_rounded_down_at_large_offsets sweeps the
offsets described above and asserts the committed height is never below the measured one — the
invariant the code commits to for nodes with a measure function.

  • Fails on main: measured height 288 was committed as 287.999756 at offset 3808.66675
  • Passes with this change
  • Full suite: 843/843

Two deliberate choices, both noted in the test:

  • Exact comparison, not ASSERT_FLOAT_EQ — the latter tolerates 4 ULPs and would not catch a
    1-ULP shortfall.
  • A sweep rather than one magic offset — the test asserts the invariant over a range, so it does
    not depend on a specific float value that might drift across platforms.

The failure message prints at max_digits10, because gtest's default float formatting renders both
sides as 288 and hides the difference.

Note that gentest fixtures cannot cover this: they are generated by rendering HTML in Chrome to
derive the expected layout, so they describe flexbox semantics, not float precision at large absolute
coordinates on a non-integer pixel scale. Hence the hand-written test.

Notes for reviewers

  • The change alters the committed value of a large share of dimensions, by well under a thousandth of
    a point. Nothing in the suite regresses (ASSERT_FLOAT_EQ's 4-ULP tolerance absorbs it), but it is
    worth being aware of.
  • Only the arithmetic changes; no rounding direction, no clamping, and no public API is modified.
  • Android is likely affected by the same root cause since Yoga is shared, though the downstream text
    engine differs (StaticLayout/Skia rather than TextKit) and its tolerance to a sub-pixel shortfall
    was not tested here.

Summary:
**problem:** `roundLayoutResultsToPixelGrid()` derives a node's width and height as the
difference of two independently rounded absolute edges. `roundValueToPixelGrid()` divides
the rounded value back by `pointScaleFactor` and narrows it to `float` before returning, so
the subtraction operates on two values that each already carry a representation error, and
that error leaks into the resulting dimension.

It only surfaces when `pointScaleFactor` makes the division inexact — `n/2` is dyadic and
always exactly representable, `n/3` almost never is — and when the absolute coordinates are
large enough for one float ULP to be significant. Sweeping 1001 grid-aligned offsets around
3800pt with `pointScaleFactor` 3, a node measured at exactly 288.0 is committed
287.999755859375 at 192 of them, one ULP short at that magnitude.

This defeats a guarantee the function makes deliberately, and which its own comment states:
"If a node has a custom measure function we never want to round down its size as this could
lead to unwanted text truncation." Each edge is rounded in the intended direction; the
subtraction of the two narrowed operands is not.

The consequence is visible in React Native. A multi-line `<Text>` far down a long list
commits a height a fraction of a point below what it measured. iOS TextKit applies a strict
"does this line fit in the remaining height" test, so that shortfall makes it drop the
entire trailing line, rendering visually truncated text with blank space left in its place
while `onLayout` and `onTextLayout` both report the complete measurement. It reproduces on
3x screens only, tracks scroll position rather than content, and survives remounting — all
consistent with the arithmetic. Measured on a minimal repro (iPhone 17 simulator, 20 fresh
launches per arm, same instrumentation, only this change differing): 19 truncated renders
before, 0 after, with the committed height going from 287.999755859375 to exactly
288.000000000000.

**fix:** round in pixel space, where a grid-aligned value is an exact integer, so the
subtraction is exact and a single narrowing happens at the end.
`roundValueToPixelGridScaled()` returns the rounded value still scaled by
`pointScaleFactor`, and `pixelGridValueToPoints()` converts back once. The public
`roundValueToPixelGrid()` keeps its exact signature and behaviour as a thin wrapper, so
`YGRoundValueToPixelGrid()` and the `Cache.cpp` callers are unaffected.

regression:
`YGRoundingMeasureFuncTest.rounding_measured_size_is_never_rounded_down_at_large_offsets`
sweeps the offsets described above and asserts the committed height is never below the
measured one. It fails on main and passes with this change. Note the exact comparison:
`ASSERT_FLOAT_EQ` tolerates 4 ULPs and would not catch a 1-ULP shortfall. The full suite
passes (843/843).

## Changelog:
[General] [Fixed] - Layout dimensions are no longer rounded below their measured size at large offsets on non-integer pixel scales
@meta-cla meta-cla Bot added the CLA Signed label Aug 26, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant