Skip to content

[Bug] CSS gradient color stop interpolation incorrect when positioned stops are adjacent to unpositioned stops #57345

Description

@xjb-git

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

  • Android
  • iOS (same algorithm in iOS implementation — needs separate check)

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

  • Android
  • iOS (same algorithm in iOS implementation — needs separate check)

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions