Skip to content

Fix flex shrink explosion when the shrink total is a positive float residual - #2044

Merged
meta-codesync[bot] merged 2 commits into
react:mainfrom
intergalacticspacehighway:fix-flex-shrink-frozen-total
Oct 20, 2026
Merged

meta-codesync[bot] merged 2 commits into
react:mainfrom
intergalacticspacehighway:fix-flex-shrink-frozen-total

Conversation

@intergalacticspacehighway

@intergalacticspacehighway intergalacticspacehighway commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2038
Fixes #1651
Closes #2039
Closes #1855

Thanks to @sebholl for the analysis and the reproduction in #2038.

Issue

When a flex line overflows, the first pass freezes the items that hit their minimum size and removes them from the shrink total. The second pass then divides the remaining free space by that total.

The total was computed by subtracting the frozen items flex shrink factors from the float total. When every item freezes, the total should become 0, but float subtraction leaves a small residual.

Example

All items have flexShrink: 1:

row (width: 350, flexDirection: row)
├── A  width: 100.1  minWidth: 90
├── B  width: 200.3  minWidth: 190
└── C  width: 300.7  minWidth: 290

The items need 601.1 but the row is only 350, so they must shrink by 251.1. Each item would go below its minWidth, so the first pass freezes all three (at 90 / 190 / 290) and removes them from the shrink total. With no items left, the total should be 0. But in float, -601.1 + 100.1 + 200.3 + 300.7 = 0.000061.

That is above the 1e-6 guard added in #1974 (for #1665), so the second pass divides the remaining free space by it: -220 / 0.000061 ≈ -3.6 million. Instead of staying at their min, the items grow to about 361M / 722M / 1084M (expected 90 / 190 / 290).

Solution

With non-negative flexShrink, every scaled shrink factor is ≤ 0, so a real total is never positive. A positive total can only be this float residual, and dividing by it is what makes the items explode. The second pass now treats a positive total like the near-zero case of the 1e-6 guard from #1974 and uses the existing fallback. Negative residuals (which the min clamp already handles) and all other layouts are unchanged. Fixing the fallback for flexShrink < 1 (#931) is left for a separate PR.

Why not inexactEquals (|total| < 0.0001)?

The leftover grows with the size of the total. For example, 60 chips of about 100pt each in a 1000pt row give a total of about 6000, and the leftover is then often larger than 0.0001. In a test with rows like this, about half of them still exploded with inexactEquals. With the sign check, none did, because a real shrink total is never positive.

The second pass divided the remaining free space by the shrink total minus the
frozen items' factors, and in float that subtraction leaves a residual (react#2038,
react#1651). Sum the factors of the items that are not frozen instead, which is
exactly 0 when none remain, and then send items to their lower bound (react#931).
@intergalacticspacehighway
intergalacticspacehighway marked this pull request as ready for review October 18, 2026 18:46
@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 Oct 18, 2026
@meta-codesync

meta-codesync Bot commented Oct 19, 2026

Copy link
Copy Markdown

@cortinico has imported this pull request. If you are a Meta employee, you can view this in D126038629.

@intergalacticspacehighway

Copy link
Copy Markdown
Collaborator Author

@cortinico Thanks for importing. Facebook Internal builds & tests seems to be failing. Could you share which tests fail?

@cortinico

Copy link
Copy Markdown
Contributor

Facebook Internal builds & tests seems to be failing. Could you share which tests fail?

Those were noisy signals. It's queued for landing now @intergalacticspacehighway 👍

@cortinico

Copy link
Copy Markdown
Contributor

@intergalacticspacehighway actually there is a real failure going on which we found in an internal React Native app.

(This is AI generated 👇)

The broken screen has a constrained 480px flex row containing content-sized filter controls. With the base Yoga revision, the icon-only Date and Metrics controls are each 75px wide, and Metrics is positioned at x=381. With this patch, Metrics gets its 169px intrinsic width; after selecting a date, it moves to x=498, completely outside the viewport.

This is verified with differential CI:

  • The patched revision failed the initial run, all retries, and double verification with the same bounds.
  • The base-revision rerun passed, with Metrics remaining at x=381, width 75.

The likely issue is this new zero-total handling:

if (flexLine.layout.totalFlexShrinkScaledFactors == 0) {
  childSize = 0;
}

Passing a synthetic zero through boundAxisWithAutoMin() can restore a content-sized automatic minimum rather than the size at which the item was frozen during the first pass. That makes previously constrained controls expand to their intrinsic widths and overflow.

Could we keep the new approach of summing the unfrozen shrink factors, but preserve each frozen item's bounded first-pass size and reuse that exact size in the second pass?

Conceptually:

if (itemWasFrozen) {
  updatedMainSize = frozenBoundMainSize;
} else {
  // Redistribute using the summed factors of the unfrozen items.
}

This would avoid the floating-point cancellation issue without using 0 as a sentinel to retrigger the constraint. It should also preserve the intended fixes for cases such as flexShrink < 1.

Also: it would be useful to add a regression test for a constrained row containing an auto-min/content-sized flexible item and non-shrinking siblings, asserting that the frozen sizes from the first pass remain unchanged in the second pass.

@intergalacticspacehighway intergalacticspacehighway changed the title Fix flex shrink sizes when the first pass freezes items Fix flex shrink explosion when the shrink total is a positive float residual Oct 19, 2026
After the first pass subtracts the frozen items' scaled shrink factors,
float cancellation can leave a residual instead of 0. When the residual
was positive, the second pass divided by it and sized items in the
billions (react#2038, react#1651). With non-negative flexShrink a real total is
never positive, so positive totals now use the existing near-zero
fallback. Negative residuals and all other layouts are unchanged.
@intergalacticspacehighway

Copy link
Copy Markdown
Collaborator Author

@cortinico I reduced the scope of this PR to the explosion fix only (#2038, #1651). A positive shrink total, which can't happen with non-negative flexShrink, now keeps the existing fallback.

The regression most likely came from the flexShrink < 1 change (#931), it made some frozen containers smaller, and existing behavior then made their children overflow. That change will be a separate PR.

Could you re-import the latest commit and rerun the internal tests? It would also help to have the styles of the failing layout, I will add it as a regression test for the follow-up.

@meta-codesync
meta-codesync Bot merged commit aa6ede7 into react:main Oct 20, 2026
36 checks passed
meta-codesync Bot pushed a commit to facebook/litho that referenced this pull request Oct 20, 2026
…esidual

Summary:
Fixes react/yoga#2038
Fixes react/yoga#1651
Closes react/yoga#2039
Closes react/yoga#1855

Thanks to sebholl for the analysis and the reproduction in react/yoga#2038.

## Issue

When a flex line overflows, the first pass freezes the items that hit their minimum size and removes them from the shrink total. The second pass then divides the remaining free space by that total.

The total was computed by subtracting the frozen items flex shrink factors from the float total. When every item freezes, the total should become 0, but float subtraction leaves a small residual.

### Example

All items have `flexShrink: 1`:

```
row (width: 350, flexDirection: row)
├── A  width: 100.1  minWidth: 90
├── B  width: 200.3  minWidth: 190
└── C  width: 300.7  minWidth: 290
```

The items need 601.1 but the row is only 350, so they must shrink by 251.1. Each item would go below its `minWidth`, so the first pass freezes all three (at 90 / 190 / 290) and removes them from the shrink total. With no items left, the total should be 0. But in float, `-601.1 + 100.1 + 200.3 + 300.7 = 0.000061`.

That is above the `1e-6` guard added in react/yoga#1974 (for react/yoga#1665), so the second pass divides the remaining free space by it: `-220 / 0.000061 ≈ -3.6 million`. Instead of staying at their min, the items grow to about **361M / 722M / 1084M** (expected **90 / 190 / 290**).

### Solution

With non-negative `flexShrink`, every scaled shrink factor is ≤ 0, so a real total is never positive. A positive total can only be this float residual, and dividing by it is what makes the items explode. The second pass now treats a positive total like the near-zero case of the `1e-6` guard from react/yoga#1974 and uses the existing fallback. Negative residuals (which the min clamp already handles) and all other layouts are unchanged. Fixing the fallback for `flexShrink < 1` (react/yoga#931) is left for a separate PR.

#### Why not `inexactEquals` (|total| < 0.0001)?
The leftover grows with the size of the total. For example, 60 chips of about 100pt each in a 1000pt row give a total of about 6000, and the leftover is then often larger than 0.0001. In a test with rows like this, about half of them still exploded with `inexactEquals`. With the sign check, none did, because a real shrink total is never positive.

X-link: react/yoga#2044

Reviewed By: javache

Differential Revision: D126038629

Pulled By: cortinico

fbshipit-source-id: dc1ec3370783e227ce4477bcd632ae5907034193
meta-codesync Bot pushed a commit to react/react-native that referenced this pull request Oct 20, 2026
…esidual

Summary:
Fixes react/yoga#2038
Fixes react/yoga#1651
Closes react/yoga#2039
Closes react/yoga#1855

Thanks to sebholl for the analysis and the reproduction in react/yoga#2038.

## Issue

When a flex line overflows, the first pass freezes the items that hit their minimum size and removes them from the shrink total. The second pass then divides the remaining free space by that total.

The total was computed by subtracting the frozen items flex shrink factors from the float total. When every item freezes, the total should become 0, but float subtraction leaves a small residual.

### Example

All items have `flexShrink: 1`:

```
row (width: 350, flexDirection: row)
├── A  width: 100.1  minWidth: 90
├── B  width: 200.3  minWidth: 190
└── C  width: 300.7  minWidth: 290
```

The items need 601.1 but the row is only 350, so they must shrink by 251.1. Each item would go below its `minWidth`, so the first pass freezes all three (at 90 / 190 / 290) and removes them from the shrink total. With no items left, the total should be 0. But in float, `-601.1 + 100.1 + 200.3 + 300.7 = 0.000061`.

That is above the `1e-6` guard added in react/yoga#1974 (for react/yoga#1665), so the second pass divides the remaining free space by it: `-220 / 0.000061 ≈ -3.6 million`. Instead of staying at their min, the items grow to about **361M / 722M / 1084M** (expected **90 / 190 / 290**).

### Solution

With non-negative `flexShrink`, every scaled shrink factor is ≤ 0, so a real total is never positive. A positive total can only be this float residual, and dividing by it is what makes the items explode. The second pass now treats a positive total like the near-zero case of the `1e-6` guard from react/yoga#1974 and uses the existing fallback. Negative residuals (which the min clamp already handles) and all other layouts are unchanged. Fixing the fallback for `flexShrink < 1` (react/yoga#931) is left for a separate PR.

#### Why not `inexactEquals` (|total| < 0.0001)?
The leftover grows with the size of the total. For example, 60 chips of about 100pt each in a 1000pt row give a total of about 6000, and the leftover is then often larger than 0.0001. In a test with rows like this, about half of them still exploded with `inexactEquals`. With the sign check, none did, because a real shrink total is never positive.

X-link: react/yoga#2044

Reviewed By: javache

Differential Revision: D126038629

Pulled By: cortinico

fbshipit-source-id: dc1ec3370783e227ce4477bcd632ae5907034193
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

2 participants