Description
Description
The ColorStopUtils.getFixedColorStops() method (Step 3 of the CSS color stop fix-up algorithm) produces incorrect positions when a color stop with an explicit position is immediately followed by a color stop without a position.
The root cause is that lastDefinedIndex is only updated when actual interpolation occurs (unpositionedStops > 0). When a positioned stop is encountered but no interpolation is needed, lastDefinedIndex fails to advance. This causes subsequent interpolation to use the wrong start point, overwriting the explicitly declared position of the skipped stop.
React Native Version
Tested on main branch. Affects at least 0.76, 0.77, 0.78, 0.79, 0.80, 0.81, 0.82.
Steps to Reproduce
import React from 'react';
import { View, StyleSheet } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<View style={styles.gradient} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
gradient: {
width: 300,
height: 100,
// stop[1] has explicit 20% position, stop[2] has no position
// Expected: [0%, 20%, 50%, 80%, 100%]
// Actual: [0%, 26.7%, 53.3%, 80%, 100%] ← stop[1]'s 20% is overwritten
backgroundImage: 'linear-gradient(red 0%, green 20%, blue, yellow 80%, purple 100%)',
},
});
Expected Behavior
Color stops should be positioned at: 0%, 20%, 50%, 80%, 100%
stop[1] (green) keeps its declared position of 20%
stop[2] (blue) is interpolated to 50% (midpoint between 20% and 80%)
Actual Behavior
Color stops are positioned at: 0%, ~26.7%, ~53.3%, 80%, 100%
stop[1] (green) has its declared 20% position overwritten by Step 3 interpolation
stop[2] (blue) is placed at ~53.3% instead of 50%
The visual result is that the green color appears at a later position than declared, and the gradient transition looks wrong compared to browser rendering.
Root Cause
In ColorStop.kt, Step 3 of getFixedColorStops():
// BEFORE (buggy)
if (hasNullPositions) {
var lastDefinedIndex = 0
for (i in 1 until fixedColorStops.size) {
val endPosition = fixedColorStops[i].position
val startPosition = fixedColorStops[lastDefinedIndex].position
val unpositionedStops = i - lastDefinedIndex - 1
if (endPosition != null && startPosition != null && unpositionedStops > 0) {
// ... interpolation ...
lastDefinedIndex = i // ✅ updated when interpolation happens
}
// ❌ NOT updated when current stop has a position but unpositionedStops == 0
}
}
Trace through the bug with [0%, 20%, null, 80%, 100%]:
i |
endPosition |
lastDefinedIndex |
unpositionedStops |
Action |
| 1 |
0.2 (✓) |
0 |
1-0-1 = 0 |
Condition false (unpositionedStops=0), lastDefinedIndex NOT updated |
| 2 |
null |
0 |
— |
Skipped |
| 3 |
0.8 (✓) |
0 (wrong!) |
3-0-1 = 2 (wrong!) |
Interpolates stop[1] and stop[2] using start=0.0, overwrites stop[1]'s 0.2 |
Fix
Add an else if branch to advance lastDefinedIndex when the current stop has a defined position but no interpolation is needed:
// AFTER (fixed)
if (endPosition != null && startPosition != null && unpositionedStops > 0) {
val increment = (endPosition - startPosition) / (unpositionedStops + 1)
for (j in 1..unpositionedStops) {
fixedColorStops[lastDefinedIndex + j] = ProcessedColorStop(
colorStops[lastDefinedIndex + j].color,
startPosition + increment * j,
)
}
lastDefinedIndex = i
} else if (endPosition != null) {
// Current stop has a position but there are no unpositioned stops between
// lastDefinedIndex and i. Still need to advance lastDefinedIndex so that
// subsequent interpolation uses the correct start point.
lastDefinedIndex = i
}
Verification
This fix has been verified to produce correct output:
| Input |
Before fix |
After fix |
[0%, 20%, null, 80%, 100%] |
[0, 0.267, 0.533, 0.8, 1.0] |
[0, 0.2, 0.5, 0.8, 1.0] ✅ |
The fix has no side effects: it only advances the pointer when no writes are performed, leaving all already-resolved stop positions unchanged.
Affected Platforms
References
Steps to reproduce
Description
The ColorStopUtils.getFixedColorStops() method (Step 3 of the CSS color stop fix-up algorithm) produces incorrect positions when a color stop with an explicit position is immediately followed by a color stop without a position.
The root cause is that lastDefinedIndex is only updated when actual interpolation occurs (unpositionedStops > 0). When a positioned stop is encountered but no interpolation is needed, lastDefinedIndex fails to advance. This causes subsequent interpolation to use the wrong start point, overwriting the explicitly declared position of the skipped stop.
React Native Version
Tested on main branch. Affects at least 0.76, 0.77, 0.78, 0.79, 0.80, 0.81, 0.82.
Steps to Reproduce
import React from 'react';
import { View, StyleSheet } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<View style={styles.gradient} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
gradient: {
width: 300,
height: 100,
// stop[1] has explicit 20% position, stop[2] has no position
// Expected: [0%, 20%, 50%, 80%, 100%]
// Actual: [0%, 26.7%, 53.3%, 80%, 100%] ← stop[1]'s 20% is overwritten
backgroundImage: 'linear-gradient(red 0%, green 20%, blue, yellow 80%, purple 100%)',
},
});
Expected Behavior
Color stops should be positioned at: 0%, 20%, 50%, 80%, 100%
stop[1] (green) keeps its declared position of 20%
stop[2] (blue) is interpolated to 50% (midpoint between 20% and 80%)
Actual Behavior
Color stops are positioned at: 0%, ~26.7%, ~53.3%, 80%, 100%
stop[1] (green) has its declared 20% position overwritten by Step 3 interpolation
stop[2] (blue) is placed at ~53.3% instead of 50%
The visual result is that the green color appears at a later position than declared, and the gradient transition looks wrong compared to browser rendering.
Root Cause
In ColorStop.kt, Step 3 of getFixedColorStops():
// BEFORE (buggy)
if (hasNullPositions) {
var lastDefinedIndex = 0
for (i in 1 until fixedColorStops.size) {
val endPosition = fixedColorStops[i].position
val startPosition = fixedColorStops[lastDefinedIndex].position
val unpositionedStops = i - lastDefinedIndex - 1
if (endPosition != null && startPosition != null && unpositionedStops > 0) {
// ... interpolation ...
lastDefinedIndex = i // ✅ updated when interpolation happens
}
// ❌ NOT updated when current stop has a position but unpositionedStops == 0
}
}
Trace through the bug with [0%, 20%, null, 80%, 100%]:
i |
endPosition |
lastDefinedIndex |
unpositionedStops |
Action |
| 1 |
0.2 (✓) |
0 |
1-0-1 = 0 |
Condition false (unpositionedStops=0), lastDefinedIndex NOT updated |
| 2 |
null |
0 |
— |
Skipped |
| 3 |
0.8 (✓) |
0 (wrong!) |
3-0-1 = 2 (wrong!) |
Interpolates stop[1] and stop[2] using start=0.0, overwrites stop[1]'s 0.2 |
Fix
Add an else if branch to advance lastDefinedIndex when the current stop has a defined position but no interpolation is needed:
// AFTER (fixed)
if (endPosition != null && startPosition != null && unpositionedStops > 0) {
val increment = (endPosition - startPosition) / (unpositionedStops + 1)
for (j in 1..unpositionedStops) {
fixedColorStops[lastDefinedIndex + j] = ProcessedColorStop(
colorStops[lastDefinedIndex + j].color,
startPosition + increment * j,
)
}
lastDefinedIndex = i
} else if (endPosition != null) {
// Current stop has a position but there are no unpositioned stops between
// lastDefinedIndex and i. Still need to advance lastDefinedIndex so that
// subsequent interpolation uses the correct start point.
lastDefinedIndex = i
}
Verification
This fix has been verified to produce correct output:
| Input |
Before fix |
After fix |
[0%, 20%, null, 80%, 100%] |
[0, 0.267, 0.533, 0.8, 1.0] |
[0, 0.2, 0.5, 0.8, 1.0] ✅ |
The fix has no side effects: it only advances the pointer when no writes are performed, leaving all already-resolved stop positions unchanged.
Affected Platforms
References
React Native Version
0.82.1
Affected Platforms
Runtime - Android
Output of npx @react-native-community/cli info
System:
OS: macOS 15.7.4
CPU: (12) arm64 Apple M3 Pro
Memory: 270.66 MB / 18.00 GB
Shell:
version: "5.9"
path: /bin/zsh
Binaries:
Node:
version: 24.1.0
path: /opt/homebrew/bin/node
Yarn:
version: 1.22.22
npm:
version: 11.3.0
Watchman:
version: 2025.09.08.00
SDKs:
Android SDK:
API Levels: 26, 28, 29, 30, 31, 33, 34, 35, 36
Build Tools: 35.0.1, 36.0.0
Android NDK: Not Found
IDEs:
Android Studio: 2024.3 AI-243.24978.46.2431.13208083
Languages:
Java:
version: 17.0.17
react-native: 0.82.1
Stacktrace or Logs
No crash or exception. This is a silent rendering bug.
The color stop positions are calculated incorrectly — the gradient renders
visually wrong (colors appear at wrong positions) compared to browser behavior,
but no error is thrown.
Verified by unit test:
Input: [red 0%, green 20%, blue (no position), yellow 80%, purple 100%]
Expected positions: [0.0, 0.2, 0.5, 0.8, 1.0]
Actual positions: [0.0, 0.267, 0.533, 0.8, 1.0]
stop[1] (green) declared position 20% is silently overwritten to ~26.7%
MANDATORY Reproducer
This bug is reproducible via unit test in ColorStop.kt without a running app. The fix and test are available at: https://github.com/facebook/react-native/blob/main/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt#L79-L101 To reproduce: 1. Clone react-native main 2. Run the existing unit test for ColorStopUtils (or add the test case below) 3. Observe that positions [0%, 20%, null, 80%, 100%] produce wrong output Test case: val stops = listOf( ColorStop(RED, LengthPercentage(0f, PERCENT)), ColorStop(GREEN, LengthPercentage(20f, PERCENT)), ColorStop(BLUE, null), ColorStop(YELLOW, LengthPercentage(80f, PERCENT)), ColorStop(PURPLE, LengthPercentage(100f, PERCENT)), ) val result = ColorStopUtils.getFixedColorStops(stops, 100f) // Expected: [0.0, 0.2, 0.5, 0.8, 1.0] // Actual: [0.0, 0.267, 0.533, 0.8, 1.0]
Screenshots and Videos
No response
Description
Description
The
ColorStopUtils.getFixedColorStops()method (Step 3 of the CSS color stop fix-up algorithm) produces incorrect positions when a color stop with an explicit position is immediately followed by a color stop without a position.The root cause is that
lastDefinedIndexis only updated when actual interpolation occurs (unpositionedStops > 0). When a positioned stop is encountered but no interpolation is needed,lastDefinedIndexfails to advance. This causes subsequent interpolation to use the wrong start point, overwriting the explicitly declared position of the skipped stop.React Native Version
Tested on
mainbranch. Affects at least 0.76, 0.77, 0.78, 0.79, 0.80, 0.81, 0.82.Steps to Reproduce
Expected Behavior
Color stops should be positioned at:
0%, 20%, 50%, 80%, 100%stop[1](green) keeps its declared position of 20%stop[2](blue) is interpolated to 50% (midpoint between 20% and 80%)Actual Behavior
Color stops are positioned at:
0%, ~26.7%, ~53.3%, 80%, 100%stop[1](green) has its declared 20% position overwritten by Step 3 interpolationstop[2](blue) is placed at ~53.3% instead of 50%The visual result is that the green color appears at a later position than declared, and the gradient transition looks wrong compared to browser rendering.
Root Cause
In
ColorStop.kt, Step 3 ofgetFixedColorStops():Trace through the bug with
[0%, 20%, null, 80%, 100%]:iendPositionlastDefinedIndexunpositionedStops1-0-1 = 0unpositionedStops=0),lastDefinedIndexNOT updated3-0-1 = 2(wrong!)stop[1]andstop[2]using start=0.0, overwrites stop[1]'s 0.2Fix
Add an
else ifbranch to advancelastDefinedIndexwhen the current stop has a defined position but no interpolation is needed:Verification
This fix has been verified to produce correct output:
[0%, 20%, null, 80%, 100%][0, 0.267, 0.533, 0.8, 1.0][0, 0.2, 0.5, 0.8, 1.0]✅The fix has no side effects: it only advances the pointer when no writes are performed, leaving all already-resolved stop positions unchanged.
Affected Platforms
References
packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.ktSteps to reproduce
Description
The
ColorStopUtils.getFixedColorStops()method (Step 3 of the CSS color stop fix-up algorithm) produces incorrect positions when a color stop with an explicit position is immediately followed by a color stop without a position.The root cause is that
lastDefinedIndexis only updated when actual interpolation occurs (unpositionedStops > 0). When a positioned stop is encountered but no interpolation is needed,lastDefinedIndexfails to advance. This causes subsequent interpolation to use the wrong start point, overwriting the explicitly declared position of the skipped stop.React Native Version
Tested on
mainbranch. Affects at least 0.76, 0.77, 0.78, 0.79, 0.80, 0.81, 0.82.Steps to Reproduce
Expected Behavior
Color stops should be positioned at:
0%, 20%, 50%, 80%, 100%stop[1](green) keeps its declared position of 20%stop[2](blue) is interpolated to 50% (midpoint between 20% and 80%)Actual Behavior
Color stops are positioned at:
0%, ~26.7%, ~53.3%, 80%, 100%stop[1](green) has its declared 20% position overwritten by Step 3 interpolationstop[2](blue) is placed at ~53.3% instead of 50%The visual result is that the green color appears at a later position than declared, and the gradient transition looks wrong compared to browser rendering.
Root Cause
In
ColorStop.kt, Step 3 ofgetFixedColorStops():Trace through the bug with
[0%, 20%, null, 80%, 100%]:iendPositionlastDefinedIndexunpositionedStops1-0-1 = 0unpositionedStops=0),lastDefinedIndexNOT updated3-0-1 = 2(wrong!)stop[1]andstop[2]using start=0.0, overwrites stop[1]'s 0.2Fix
Add an
else ifbranch to advancelastDefinedIndexwhen the current stop has a defined position but no interpolation is needed:Verification
This fix has been verified to produce correct output:
[0%, 20%, null, 80%, 100%][0, 0.267, 0.533, 0.8, 1.0][0, 0.2, 0.5, 0.8, 1.0]✅The fix has no side effects: it only advances the pointer when no writes are performed, leaving all already-resolved stop positions unchanged.
Affected Platforms
References
packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.ktReact Native Version
0.82.1
Affected Platforms
Runtime - Android
Output of
npx @react-native-community/cli infoStacktrace or Logs
MANDATORY Reproducer
This bug is reproducible via unit test in ColorStop.kt without a running app. The fix and test are available at: https://github.com/facebook/react-native/blob/main/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt#L79-L101 To reproduce: 1. Clone react-native main 2. Run the existing unit test for ColorStopUtils (or add the test case below) 3. Observe that positions [0%, 20%, null, 80%, 100%] produce wrong output Test case: val stops = listOf( ColorStop(RED, LengthPercentage(0f, PERCENT)), ColorStop(GREEN, LengthPercentage(20f, PERCENT)), ColorStop(BLUE, null), ColorStop(YELLOW, LengthPercentage(80f, PERCENT)), ColorStop(PURPLE, LengthPercentage(100f, PERCENT)), ) val result = ColorStopUtils.getFixedColorStops(stops, 100f) // Expected: [0.0, 0.2, 0.5, 0.8, 1.0] // Actual: [0.0, 0.267, 0.533, 0.8, 1.0]
Screenshots and Videos
No response