Skip to content

Fix flex-shrink runaway when the sole shrinkable item has an aspectRatio basis - #2039

Closed
sebholl wants to merge 1 commit into
react:mainfrom
sebholl:fix/flex-shrink-aspect-ratio-residual
Closed

sebholl wants to merge 1 commit into
react:mainfrom
sebholl:fix/flex-shrink-aspect-ratio-residual

Conversation

@sebholl

@sebholl sebholl commented Oct 14, 2026

Copy link
Copy Markdown

Summary

Fixes #2038.

When the only shrinkable item on an overflowing row has its flex basis derived from aspectRatio, distributeFreeSpaceSecondPass divides the remaining free space by a 2^-19 residual left in totalFlexShrinkScaledFactors, and the item is measured at ~8.3e9 instead of 0. Sibling of #1665 / #1974 / #1783; the absolute 1e-6f guard from #1974 does not catch this residual.

Mechanism (full trace in #2038): calculateFlexLine accumulates round(-3 * 21.083334f) = -63.25. distributeFreeSpaceFirstPass then subtracts the same product inline, which clang contracts into a fused multiply-add using the unrounded product 63.2500019, leaving exactly 2^-19 = 1.9e-6. Building with -ffp-contract=off makes the bug disappear, which is also why it never shows in the wasm playground.

Two changes:

  1. distributeFreeSpaceFirstPass: round the excluded product to float before subtracting it. This is the cause, and on its own it fixes the repro under the default -ffp-contract=on.
  2. Replace the 1e-6f epsilon in distributeFreeSpaceSecondPass with an exact count of shrinkable items left on the line (FlexLineRunningLayout::numberOfShrinkableItems, kept in lockstep with the total). "Are any shrinkable items left?" is a counting question, and no absolute epsilon is right because the residual scales with flexShrink * flexBasis. This also keeps the fix correct under -ffp-contract=fast, where (1) alone can still be contracted across statements.

Left alone deliberately: the first-pass division (remainingFreeSpace / totalFlexShrinkScaledFactors inside distributeFreeSpaceFirstPass) is structurally exposed to the same issue if an item freezes mid-loop, but I have no repro for it, and a count-based guard cannot apply there because the current item is not yet frozen. Happy to add something if a maintainer prefers.

Test plan

🤖 This PR includes AI-assisted changes via Claude Code.

…tio basis

Fixes react#2038.

distributeFreeSpaceFirstPass subtracted the frozen item's shrink-scaled
factor inline, which the compiler contracts into a fused multiply-add with
the unrounded product. It therefore did not cancel against the float
accumulator built in calculateFlexLine and left a 2^-19 residual that
distributeFreeSpaceSecondPass divided the remaining free space by,
measuring the item at ~8.3e9 instead of 0. The absolute 1e-6f guard from
react#1974 does not cover it.

Round the product to float before subtracting, and replace the epsilon
with an exact count of shrinkable items left on the line.

🤖 Co-authored-by: Claude <noreply@anthropic.com>
@intergalacticspacehighway

Copy link
Copy Markdown
Collaborator

Thanks @sebholl for the analysis and repro! I've opened #2044, which sums the shrink factors of the unfrozen items instead of subtracting the frozen ones. That makes the total itself exact (0 when every item is frozen), so no separate count is needed, and it also fixes #931. I've also included your aspectRatio tests there.

@sebholl

sebholl commented Oct 20, 2026

Copy link
Copy Markdown
Author

Closing in favor of @intergalacticspacehighway 's fix.

@sebholl sebholl closed this Oct 20, 2026
meta-codesync Bot pushed a commit that referenced this pull request Oct 20, 2026
…esidual

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.

Differential Revision: D126038629

Pull Request resolved: #2044
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
cipolleschi pushed a commit to react/react-native that referenced this pull request Oct 26, 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
(cherry picked from commit 24a3be5)
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.

flexShrink runaway (~8e9) when the sole shrinkable item has an aspectRatio basis; FMA residual slips past the 1e-6f guard from #1974

2 participants