Skip to content

Fix text truncation caused by pixel-grid rounding of near-integer text nodes - #1975

Closed
stleamist wants to merge 1 commit into
react:mainfrom
stleamist:fix-text-truncation-pixel-grid
Closed

stleamist wants to merge 1 commit into
react:mainfrom
stleamist:fix-text-truncation-pixel-grid

Conversation

@stleamist

Copy link
Copy Markdown

Summary

Fixes a text truncation bug in roundLayoutResultsToPixelGrid(): when a text node's scaled dimension is close to a whole number (hasFractionalWidth == false), the trailing edge was force-floored. Because the inexactEquals tolerance (0.0001) can apply asymmetrically to the two edges of the same node, the leading edge may snap up while the trailing edge gets floored, producing a final width smaller than the measured text width — so the text renders with an ellipsis.

Real-world case

Observed with a right-aligned UILabel laid out via FlexLayout on an @3x display (393pt-wide device). The measured text width was 54.666656, positioned at absoluteNodeLeft = 163.666641:

Value × 3 (scale) distance from whole number within 0.0001 tolerance? result
nodeWidth = 54.666656 163.999968 0.000032 yes → hasFractionalWidth = false forceFloor enabled
absoluteNodeLeft = 163.666641 490.999923 0.000077 yes snaps up to 491
absoluteNodeRight = 218.333297 654.999891 0.000109 no floored down to 654

Final width = (654 − 491) / 3 = 54.333… < measured 54.666656 → truncated.

Both edges sit essentially on the same pixel boundary, but a 0.00003 difference makes them round in opposite directions, shrinking the node by a full pixel.

Fix

Never force-floor the trailing edge of a text node:

  • hasFractionalWidth == true (clearly fractional): keep the existing force-ceil behavior.
  • hasFractionalWidth == false (near-integer): fall through to natural rounding, which snaps 654.999891 up to 655 and preserves the measured width.

This also matches the intent of the existing comment in the function: "If a node has a custom measure function we never want to round down its size as this could lead to unwanted text truncation."

Tests

  • All existing tests pass unchanged (831 passed, 46 skipped, 0 failed).
  • Adds tests/YGRoundingTextTruncationTest.cpp with four tests, including one reproducing the exact values from the real-world case above (fails before the fix, passes after).

Related issues

…t nodes

When rounding layout results to the pixel grid, text nodes whose scaled
dimension is close to a whole number (hasFractionalWidth == false) had
their trailing edge force-floored. The inexactEquals tolerance (0.0001)
can apply asymmetrically to the two edges of the same node: the leading
edge falls inside the tolerance and snaps up, while the trailing edge
falls just outside it and gets floored. The resulting width is smaller
than the measured text width, so text renders with an ellipsis.

Real-world case (UILabel on an @3x display, observed via FlexLayout):
- absoluteNodeLeft  * 3 = 490.999923 -> within 0.0001 of 491 -> snaps UP
- absoluteNodeRight * 3 = 654.999894 -> outside tolerance -> floored DOWN
- final width 54.333... < measured 54.666656 -> truncated

Fix: never force-floor the trailing edge of a text node. Clearly
fractional sizes keep the existing force-ceil behavior; near-integer
sizes fall through to natural rounding, which snaps 654.999894 up to
655 and preserves the measured width.

All existing tests pass unchanged. Adds YGRoundingTextTruncationTest
reproducing the bug with the exact values from the real-world case.
@meta-cla meta-cla Bot added the CLA Signed label Jun 12, 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 Jun 12, 2026
@stleamist

Copy link
Copy Markdown
Author

I've put together a minimal iOS reproduction of this bug that uses nothing but the yoga C API and a single UILabel, with snapshot tests: https://github.com/stleamist/YogaTruncationRepro

The label is measured by a yoga measure function and laid out on a @3x pixel grid (iPhone 15 Pro simulator) at an offset where the node's left edge scales to just inside the 0.0001 inexactEquals tolerance of a whole pixel (so it snaps up) while the right edge lands just outside it (so it gets force-floored). Yoga assigns the label exactly one pixel less than what its measure function returned, and the label renders with an ellipsis:

XCTAssertGreaterThanOrEqual failed: ("45.0") is less than ("45.33332266666667")
yoga main (f6206ec3) with this PR
truncated not truncated

The core of the trigger (full code in the repo):

// Measure the real label, then nudge the width up to the next value whose
// scaled size sits just inside the tolerance of a whole pixel — the same
// shape of value that real text measurement produces naturally. This makes
// hasFractionalWidth == false, enabling the force-floor path.
CGSize fitted = [view.label sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)];
double wholePixels = ceil(fitted.width * 3.0);
double measuredWidth = (wholePixels - 0.000032) / 3.0;

// Left edge: scales to 490.999923, *within* 0.0001 of 491 → snaps UP.
// Right edge (left + width): ends up ~0.000109 away from a whole pixel,
// *outside* the tolerance → force-floored DOWN. The node loses one pixel.
YGNodeStyleSetPadding(root, YGEdgeLeft, (float)((491.0 - 0.000077) / 3.0));

The package pins yoga to current main (f6206ec3), so both tests fail out of the box. Swapping the dependency for this PR's branch (a one-line change noted in Package.swift) makes both pass. The snapshot test's committed reference is the correct rendering, so its failure diff visualizes the truncation directly.

@stleamist

stleamist commented Jun 23, 2026 •

Copy link
Copy Markdown
Author

Hi @NickGerleman — hope it's okay to tag you directly on this.

I realize Yoga's changes mostly flow through Meta's internal review, so this PR may just be waiting on someone to pick it up. Since it lands in the pixel-grid rounding code you maintain, I wanted to flag it — and I've tried to make it as low-effort to verify as possible:

  • A minimal iOS repro using only the yoga C API + one UILabel, with snapshot tests that fail on main and pass with this PR: https://github.com/stleamist/YogaTruncationRepro
  • The fix covers both width and height.
  • All 831 existing tests pass (46 skipped, 0 failed), plus 4 new regression tests for the offset/tolerance edge case.

No urgency at all — happy to follow whatever process works best on your end, and to rebase or adjust however helps.

@stleamist

Copy link
Copy Markdown
Author

Hi @cipolleschi @javache @cortinico, could one of you help review this PR or point me to the current owner of Yoga’s pixel-grid rounding code?

This has been awaiting review since June. It fixes a one-pixel reduction in measured text dimensions that causes UILabel truncation on @3x displays. I checked current main (0825043), and the force-floor logic addressed by this PR is still present.

The PR includes four C++ regression tests and a minimal Yoga C API + UILabel reproduction with before/after snapshots: https://github.com/stleamist/YogaTruncationRepro

The Import Status check is still queued. Could you advise on the next step to get this reviewed? I’m happy to rebase, rerun the tests against current main, or adjust the fix if there are compatibility concerns.

Thanks for your help!

@cortinico

Copy link
Copy Markdown
Contributor

could one of you help review this PR or point me to the current owner of Yoga’s pixel-grid rounding code?

cc @intergalacticspacehighway

@intergalacticspacehighway

Copy link
Copy Markdown
Collaborator

Thanks for the PR @stleamist. I added a fix here - #2040. It is a bit different from this PR. For a text node whose size is already a whole number of pixels, it sets the size to that pixel count directly, instead of rounding the end edge.
The reason is that removing the floor on the end edge fixes the lost pixel, but it adds a pixel to whole pixel text size when its start edge sits at a pixel fraction of 0.5 or more.

For example, at 3x scale, 10pt (30px) size text node starting at 0.2pt (0.6px):

Start (0.6px) End (30.6px) Size
current floor → 0 floor → 30 30px ✅
this PR floor → 0 round → 31 31px ❌
#2040 floor → 0 0 + 30px 30px ✅

@stleamist

Copy link
Copy Markdown
Author

Thanks @intergalacticspacehighway! I verified #2040 against the original 36.5°C UIKit repro, and it resolves the truncation. The rendered snapshot is pixel-identical to the result with this PR.

I’m happy for #2040 to supersede this PR. Thanks for looking into this and putting together the fix!

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.

3 participants