Subtract rounded edges in pixel space when rounding layout results - #2011
Open
Ge0ffreyS wants to merge 1 commit into
Open
Subtract rounded edges in pixel space when rounding layout results#2011Ge0ffreyS wants to merge 1 commit into
Ge0ffreyS wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 twoindependently rounded absolute edges:
roundValueToPixelGrid()divides the rounded value back bypointScaleFactorand narrows theresult to
floatbefore returning. The subtraction therefore operates on two values that eachalready carry a representation error, and that error leaks into the resulting dimension.
Two conditions have to line up for it to be visible:
pointScaleFactormakes the division inexact.n/2is dyadic and always exactlyrepresentable, so a 2x screen never produces an error.
n/3almost never is.the magnitude of the operands, so it appears for nodes far down a long scrolling container.
Sweeping 1001 grid-aligned offsets around 3800pt with
pointScaleFactor3, a node measured atexactly
288.0is committed287.999755859375at 192 of them — one float ULP short at thatmagnitude.
This defeats a guarantee the function makes deliberately, and which its own comment already states:
That is why
textRoundingforces ceil on one edge and floor on the other. Each edge is rounded inthe 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 itmeasured. 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
onLayoutandonTextLayoutboth 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):
287.999755859375288.000000000000Fix
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 bypointScaleFactor.pixelGridValueToPoints()converts back to points, once.roundValueToPixelGrid()keeps its exact signature and behaviour as a thin wrapper,so
YGRoundValueToPixelGrid()and theCache.cppcallers are untouched.Test
YGRoundingMeasureFuncTest.rounding_measured_size_is_never_rounded_down_at_large_offsetssweeps theoffsets 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.
main:measured height 288 was committed as 287.999756 at offset 3808.66675Two deliberate choices, both noted in the test:
ASSERT_FLOAT_EQ— the latter tolerates 4 ULPs and would not catch a1-ULP shortfall.
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 bothsides as
288and 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
a point. Nothing in the suite regresses (
ASSERT_FLOAT_EQ's 4-ULP tolerance absorbs it), but it isworth being aware of.
engine differs (
StaticLayout/Skia rather than TextKit) and its tolerance to a sub-pixel shortfallwas not tested here.