From 7ca97d39e6363cbfa715a5f4356a69804b6de23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Tue, 3 Nov 2026 04:47:07 +0530 Subject: [PATCH 1/5] =?UTF-8?q?Interpolate=20gradient=20color=20stops=20ou?= =?UTF-8?q?tside=20the=200=E2=80=93100%=20range?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS lets color stops sit before 0% or after 100%. They still shape the colors inside the gradient through interpolation, but are never painted directly. React Native clamped such stops to the range on iOS, and Android's gradient shaders pin them, so `linear-gradient(red -50%, blue 150%)` rendered as plain `red → blue`, while browsers start at rgb(191, 0, 64). Replace out-of-range stops with stops at the range boundaries, colored as the gradient would be at that point (premultiplied, as CSS and Android's shaders interpolate). Radial gradients only clip negative stops: like Blink, they grow the radius to fit stops past the ending shape, because that area stays visible. Also like Blink, length stops on a zero-length gradient line resolve to 0, so the radius stays finite. Android also dropped negative stop positions while parsing linear and radial gradients, treating them as unpositioned. Allow them. --- .../React/Fabric/Utils/RCTGradientUtils.h | 4 + .../React/Fabric/Utils/RCTGradientUtils.mm | 70 ++++- .../React/Fabric/Utils/RCTLinearGradient.mm | 5 +- .../React/Fabric/Utils/RCTRadialGradient.mm | 11 +- .../react/uimanager/style/ColorStop.kt | 95 +++++- .../react/uimanager/style/LinearGradient.kt | 8 +- .../react/uimanager/style/RadialGradient.kt | 15 +- .../react/uimanager/style/ColorStopTest.kt | 281 ++++++++++++++++++ .../api-snapshots/ReactAppleDebugCxx.api | 1 + .../api-snapshots/ReactAppleFrameworksCxx.api | 1 + .../api-snapshots/ReactAppleNewarchCxx.api | 1 + .../api-snapshots/ReactAppleReleaseCxx.api | 1 + 12 files changed, 481 insertions(+), 12 deletions(-) diff --git a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.h b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.h index a83c563470ad..311b36dc784a 100644 --- a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.h +++ b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.h @@ -17,6 +17,10 @@ NS_ASSUME_NONNULL_BEGIN (const std::vector &)colorStops gradientLineLength:(CGFloat)gradientLineLength; ++ (std::vector) + clipColorStops:(const std::vector &)colorStops + end:(facebook::react::Float)end; + // CAGradientLayer linear gradient squishes the non-square gradient to square gradient. // This function fixes the "squished" effect. // See https://stackoverflow.com/a/43176174 for more information. diff --git a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm index ee0f5ff8e0c9..446bf254ff55 100644 --- a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm +++ b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm @@ -9,6 +9,8 @@ #import #import #import +#include +#include #include #import @@ -146,7 +148,7 @@ CGSize calculateMultipliers(CGSize bounds) static std::optional resolveColorStopPosition(ValueUnit position, CGFloat gradientLineLength) { if (position.unit == UnitType::Point) { - return position.resolve(0.0f) / gradientLineLength; + return gradientLineLength > 0 ? position.resolve(0.0f) / gradientLineLength : 0; } if (position.unit == UnitType::Percent) { @@ -156,6 +158,29 @@ CGSize calculateMultipliers(CGSize bounds) return std::nullopt; } +// Interpolates with premultiplied alpha, as CSS does. +// Spec: https://drafts.csswg.org/css-color-4/#interpolation-alpha +static SharedColor interpolateColor(const SharedColor &from, const SharedColor &to, float weight) +{ + if (!from || !to) { + return from ? from : to; + } + auto start = colorComponentsFromColor(from); + auto end = colorComponentsFromColor(to); + auto alpha = start.alpha + (end.alpha - start.alpha) * weight; + if (alpha <= 0) { + return clearColor(); + } + auto channel = [&](float startChannel, float endChannel) { + return (startChannel * start.alpha * (1 - weight) + endChannel * end.alpha * weight) / alpha; + }; + return colorFromComponents( + {.red = channel(start.red, end.red), + .green = channel(start.green, end.green), + .blue = channel(start.blue, end.blue), + .alpha = alpha}); +} + // Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line (Refer transition hint section) // Browsers add 9 intermediate color stops when a transition hint is present // Algorithm is referred from Blink engine @@ -336,6 +361,49 @@ @implementation RCTGradientUtils return processColorTransitionHints(fixedColorStops); } +// Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line +// Color stops outside [0, end] still affect the colors inside it through interpolation, but are never painted +// directly, so they are replaced by stops at the range boundaries, colored as the gradient would be at that point. ++ (std::vector)clipColorStops:(const std::vector &)colorStops end:(Float)end +{ + if (colorStops.empty() || + (colorStops.front().position.value_or(0) >= 0 && colorStops.back().position.value_or(0) <= end)) { + return colorStops; + } + + auto firstInside = std::find_if( + colorStops.begin(), colorStops.end(), [](const auto &stop) { return stop.position.value_or(0) >= 0; }); + auto lastInside = std::find_if( + colorStops.rbegin(), colorStops.rend(), [end](const auto &stop) { return stop.position.value_or(0) <= end; }); + if (firstInside == colorStops.end()) { + return {{.color = colorStops.back().color, .position = 0}, {.color = colorStops.back().color, .position = end}}; + } + if (lastInside == colorStops.rend()) { + return {{.color = colorStops.front().color, .position = 0}, {.color = colorStops.front().color, .position = end}}; + } + + auto colorAt = [](const ProcessedColorStop &before, const ProcessedColorStop &after, Float position) { + auto weight = (position - before.position.value()) / (after.position.value() - before.position.value()); + return interpolateColor(before.color, after.color, static_cast(weight)); + }; + + std::vector clippedColorStops; + if (firstInside != colorStops.begin() && firstInside->position.value() > 0) { + clippedColorStops.push_back({.color = colorAt(*std::prev(firstInside), *firstInside, 0), .position = 0}); + } + auto insideEnd = lastInside.base(); + if (firstInside < insideEnd) { + clippedColorStops.insert(clippedColorStops.end(), firstInside, insideEnd); + } + if (insideEnd != colorStops.end() && std::prev(insideEnd)->position.value() < end) { + clippedColorStops.push_back({.color = colorAt(*std::prev(insideEnd), *insideEnd, end), .position = end}); + } + if (clippedColorStops.size() == 1) { + clippedColorStops.push_back({.color = clippedColorStops.front().color, .position = end}); + } + return clippedColorStops; +} + // CAGradientLayer linear gradient squishes the non-square gradient to square gradient. // This function fixes the "squished" effect. // See https://stackoverflow.com/a/43176174 for more information. diff --git a/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm b/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm index 2aa56c860df4..c52032e4bf78 100644 --- a/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm +++ b/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm @@ -36,8 +36,9 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const LinearGradient & CGFloat dx = endPoint.x - startPoint.x; CGFloat dy = endPoint.y - startPoint.y; CGFloat gradientLineLength = sqrt(dx * dx + dy * dy); - const auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops - gradientLineLength:gradientLineLength]; + const auto fixedColorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops + gradientLineLength:gradientLineLength]; + const auto colorStops = [RCTGradientUtils clipColorStops:fixedColorStops end:1]; NSMutableArray *colors = [NSMutableArray array]; NSMutableArray *locations = [NSMutableArray array]; CGPoint relativeStartPoint = CGPointMake(startPoint.x / size.width, startPoint.y / size.height); diff --git a/packages/react-native/React/Fabric/Utils/RCTRadialGradient.mm b/packages/react-native/React/Fabric/Utils/RCTRadialGradient.mm index 35bcd9ede9ba..2080cc27e376 100644 --- a/packages/react-native/React/Fabric/Utils/RCTRadialGradient.mm +++ b/packages/react-native/React/Fabric/Utils/RCTRadialGradient.mm @@ -175,8 +175,15 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const RadialGradient & auto [radiusX, radiusY] = GetRadialGradientRadius(isCircle, gradient.size, centerPoint.x, centerPoint.y, size.width, size.height); const auto gradientLineLength = std::max(radiusX, radiusY); - const auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops - gradientLineLength:gradientLineLength]; + auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops gradientLineLength:gradientLineLength]; + // Unlike negative stops, stops past the ending shape stay visible outside it, so the radius grows to fit them. + const Float radiusScale = colorStops.empty() ? 1 : std::max(colorStops.back().position.value_or(1), 1); + colorStops = [RCTGradientUtils clipColorStops:colorStops end:radiusScale]; + for (auto &colorStop : colorStops) { + colorStop.position = colorStop.position.value_or(0) / radiusScale; + } + radiusX *= radiusScale; + radiusY *= radiusScale; gradientLayer.startPoint = CGPointMake(centerPoint.x / size.width, centerPoint.y / size.height); // endpoint.x is horizontal length and endpoint.y is vertical length gradientLayer.endPoint = CGPointMake( diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt index 32e101bcbcf1..220b81a34db4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt @@ -7,12 +7,14 @@ package com.facebook.react.uimanager.style +import android.graphics.Color import androidx.core.graphics.ColorUtils import com.facebook.react.uimanager.FloatUtil import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.PixelUtil import kotlin.math.ln +import kotlin.math.roundToInt /** * Represents a color stop in a gradient as specified by the user. @@ -137,6 +139,93 @@ internal object ColorStopUtils { return processColorTransitionHints(fixedColorStops) } + /** + * Replaces color stops outside `[0, end]` with stops at the range boundaries. + * + * Stops outside the range still affect the colors inside it through interpolation, but are never + * painted directly. Android's gradient shaders pin them to the range instead, so each boundary + * gets the color the gradient would have at that point. + * + * @see CSS Images Module + * Level 4 + */ + fun clipColorStops( + colorStops: List, + end: Float = 1f, + ): List { + val firstPosition = colorStops.firstOrNull()?.position ?: return colorStops + val lastPosition = colorStops.last().position ?: return colorStops + if (firstPosition >= 0f && lastPosition <= end) { + return colorStops + } + + val firstInside = colorStops.indexOfFirst { (it.position ?: 0f) >= 0f } + val lastInside = colorStops.indexOfLast { (it.position ?: 0f) <= end } + if (firstInside == -1) { + val color = colorStops.last().color + return listOf(ProcessedColorStop(color, 0f), ProcessedColorStop(color, end)) + } + if (lastInside == -1) { + val color = colorStops.first().color + return listOf(ProcessedColorStop(color, 0f), ProcessedColorStop(color, end)) + } + + val clippedColorStops = ArrayList(colorStops.size) + if (firstInside > 0 && (colorStops[firstInside].position ?: 0f) > 0f) { + clippedColorStops.add( + ProcessedColorStop(colorAt(colorStops[firstInside - 1], colorStops[firstInside], 0f), 0f) + ) + } + for (i in firstInside..lastInside) { + clippedColorStops.add(colorStops[i]) + } + if (lastInside < colorStops.lastIndex && (colorStops[lastInside].position ?: 0f) < end) { + clippedColorStops.add( + ProcessedColorStop(colorAt(colorStops[lastInside], colorStops[lastInside + 1], end), end) + ) + } + if (clippedColorStops.size == 1) { + clippedColorStops.add(ProcessedColorStop(clippedColorStops[0].color, end)) + } + return clippedColorStops + } + + private fun colorAt( + before: ProcessedColorStop, + after: ProcessedColorStop, + position: Float, + ): Int? { + val beforeColor = before.color ?: return after.color + val afterColor = after.color ?: return beforeColor + val beforePosition = before.position ?: return beforeColor + val afterPosition = after.position ?: return afterColor + return interpolateColor( + beforeColor, + afterColor, + (position - beforePosition) / (afterPosition - beforePosition), + ) + } + + // Interpolates with premultiplied alpha, as CSS and Android's gradient shaders do. + // Spec: https://drafts.csswg.org/css-color-4/#interpolation-alpha + private fun interpolateColor(from: Int, to: Int, weight: Float): Int { + val fromAlpha = Color.alpha(from) / 255f + val toAlpha = Color.alpha(to) / 255f + val alpha = fromAlpha + (toAlpha - fromAlpha) * weight + if (alpha <= 0f) { + return Color.TRANSPARENT + } + fun channel(fromChannel: Int, toChannel: Int): Int = + ((fromChannel * fromAlpha * (1f - weight) + toChannel * toAlpha * weight) / alpha) + .roundToInt() + return Color.argb( + (alpha * 255f).roundToInt(), + channel(Color.red(from), Color.red(to)), + channel(Color.green(from), Color.green(to)), + channel(Color.blue(from), Color.blue(to)), + ) + } + // Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line (Refer transition hint // section) // Browsers add 9 intermediate color stops when a transition hint is present @@ -244,7 +333,11 @@ internal object ColorStopUtils { return when (position.type) { LengthPercentageType.POINT -> - PixelUtil.toPixelFromDIP(position.resolve(0f)) / gradientLineLength + if (gradientLineLength > 0f) { + PixelUtil.toPixelFromDIP(position.resolve(0f)) / gradientLineLength + } else { + 0f + } LengthPercentageType.PERCENT -> position.resolve(1f) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/LinearGradient.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/LinearGradient.kt index 41050bf0a358..f46c90c2b136 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/LinearGradient.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/LinearGradient.kt @@ -73,7 +73,10 @@ internal class LinearGradient(val direction: Direction, val colorStops: List= 0f } + assertThat(clipped.last().color).isEqualTo(Color.BLUE) + assertThat(clipped.last().position).isEqualTo(0.65f) + } + + // Mirrors WPT css/css-images/normalization-linear.html and normalization-radial.html. + @Test + fun testCoincidentStopsBelowRangeRenderLastColor() { + val fixed = + ColorStopUtils.getFixedColorStops( + listOf( + ColorStop(Color.GREEN, LengthPercentage(-50f, LengthPercentageType.PERCENT)), + ColorStop(Color.BLUE, LengthPercentage(-50f, LengthPercentageType.PERCENT)), + ), + 100f, + ) + + assertThat(ColorStopUtils.clipColorStops(fixed).map { it.color }) + .containsExactly(Color.BLUE, Color.BLUE) + } + + // Mirrors WPT css/css-images/normalization-linear-2.html. + @Test + fun testCoincidentStopsAboveRangeRenderFirstColor() { + val fixed = + ColorStopUtils.getFixedColorStops( + listOf( + ColorStop(Color.BLUE, LengthPercentage(150f, LengthPercentageType.PERCENT)), + ColorStop(Color.RED, LengthPercentage(150f, LengthPercentageType.PERCENT)), + ), + 100f, + ) + + assertThat(ColorStopUtils.clipColorStops(fixed).map { it.color }) + .containsExactly(Color.BLUE, Color.BLUE) + } + + // Mirrors WPT css/css-images/normalization-radial-2.html: radial gradients keep stops past the + // ending shape, and grow the radius to fit them. + @Test + fun testClipColorStopsKeepsStopsUpToCustomEnd() { + val colorStops = + listOf(ProcessedColorStop(Color.BLUE, 1.5f), ProcessedColorStop(Color.RED, 1.5f)) + + assertThat(ColorStopUtils.clipColorStops(colorStops, end = 1.5f)).isSameAs(colorStops) + } + + @Test + fun testLengthPositionsOnZeroLengthGradientLineResolveToZero() { + val fixed = + ColorStopUtils.getFixedColorStops( + listOf( + ColorStop(Color.RED, LengthPercentage(10f, LengthPercentageType.POINT)), + ColorStop(Color.BLUE, LengthPercentage(20f, LengthPercentageType.POINT)), + ), + 0f, + ) + + assertThat(fixed.map { it.position }).containsExactly(0f, 0f) + } + + @Test + fun testGradientsParseNegativeColorStopPositions() { + val colorStops = + JavaOnlyArray.of( + JavaOnlyMap.of("color", Color.RED, "position", "-50%"), + JavaOnlyMap.of("color", Color.BLUE, "position", -20.0), + ) + val context = RuntimeEnvironment.getApplication() + + val linear = + LinearGradient.parse( + JavaOnlyMap.of( + "direction", + JavaOnlyMap.of("type", "angle", "value", 90.0), + "colorStops", + colorStops, + ), + context, + ) as LinearGradient + val radial = + RadialGradient.parse( + JavaOnlyMap.of( + "shape", + "circle", + "size", + "farthest-corner", + "position", + JavaOnlyMap.of("top", "50%", "left", "50%"), + "colorStops", + colorStops, + ), + context, + ) as RadialGradient + + for (stops in listOf(linear.colorStops, radial.colorStops)) { + assertThat(stops[0].position?.type).isEqualTo(LengthPercentageType.PERCENT) + assertThat(stops[0].position?.resolve(1f)).isEqualTo(-0.5f) + assertThat(stops[1].position?.type).isEqualTo(LengthPercentageType.POINT) + assertThat(stops[1].position?.resolve(0f)).isEqualTo(-20f) + } + } } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 0dd275db8b98..7c15b57c0ca2 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1093,6 +1093,7 @@ interface RCTGenericDelegateSplitter : public NSObject { interface RCTGradientUtils : public NSObject { public virtual static std::pair pointsForCAGradientLayerLinearGradient:endPoint:bounds:(CGPoint startPoint, CGPoint endPoint, CGSize bounds); + public virtual static std::vector clipColorStops:end:(const std::vector& colorStops, facebook::react::Float end); public virtual static std::vector getFixedColorStops:gradientLineLength:(const std::vector& colorStops, CGFloat gradientLineLength); public virtual static void getColors:andLocations:fromColorStops:(NSMutableArray* colors, NSMutableArray* locations, const std::vector& colorStops); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index af03ebeb5a9c..17755c7d808a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -1102,6 +1102,7 @@ interface RCTGenericDelegateSplitter : public NSObject { interface RCTGradientUtils : public NSObject { public virtual static std::pair pointsForCAGradientLayerLinearGradient:endPoint:bounds:(CGPoint startPoint, CGPoint endPoint, CGSize bounds); + public virtual static std::vector clipColorStops:end:(const std::vector& colorStops, facebook::react::Float end); public virtual static std::vector getFixedColorStops:gradientLineLength:(const std::vector& colorStops, CGFloat gradientLineLength); public virtual static void getColors:andLocations:fromColorStops:(NSMutableArray* colors, NSMutableArray* locations, const std::vector& colorStops); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 6de636e0f56d..e03eafed6af7 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1093,6 +1093,7 @@ interface RCTGenericDelegateSplitter : public NSObject { interface RCTGradientUtils : public NSObject { public virtual static std::pair pointsForCAGradientLayerLinearGradient:endPoint:bounds:(CGPoint startPoint, CGPoint endPoint, CGSize bounds); + public virtual static std::vector clipColorStops:end:(const std::vector& colorStops, facebook::react::Float end); public virtual static std::vector getFixedColorStops:gradientLineLength:(const std::vector& colorStops, CGFloat gradientLineLength); public virtual static void getColors:andLocations:fromColorStops:(NSMutableArray* colors, NSMutableArray* locations, const std::vector& colorStops); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 7ab648c8073e..a08d8a61fc2f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1093,6 +1093,7 @@ interface RCTGenericDelegateSplitter : public NSObject { interface RCTGradientUtils : public NSObject { public virtual static std::pair pointsForCAGradientLayerLinearGradient:endPoint:bounds:(CGPoint startPoint, CGPoint endPoint, CGSize bounds); + public virtual static std::vector clipColorStops:end:(const std::vector& colorStops, facebook::react::Float end); public virtual static std::vector getFixedColorStops:gradientLineLength:(const std::vector& colorStops, CGFloat gradientLineLength); public virtual static void getColors:andLocations:fromColorStops:(NSMutableArray* colors, NSMutableArray* locations, const std::vector& colorStops); } From 72be68630106d123b77a24c568161ec3e209fa36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Tue, 3 Nov 2026 11:09:49 +0530 Subject: [PATCH 2/5] Add iOS unit tests for out-of-range gradient color stops Mirror the Android ColorStopTest cases for RCTGradientUtils clipColorStops and getFixedColorStops in the React-RCTFabric test spec. Credit the WPT tests that some cases come from, in both the iOS and Kotlin tests. --- .../Tests/Fabric/RCTGradientUtilsTests.mm | 246 ++++++++++++++++++ .../react/uimanager/style/ColorStopTest.kt | 12 +- 2 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm diff --git a/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm b/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm new file mode 100644 index 000000000000..e6d569341c5a --- /dev/null +++ b/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm @@ -0,0 +1,246 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import + +#import + +using namespace facebook::react; + +static SharedColor rgba(uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha = 255) +{ + return colorFromRGBA(red, green, blue, alpha); +} + +static NSString *describeColor(const SharedColor &color) +{ + if (!color) { + return @"null"; + } + return [NSString stringWithFormat:@"rgba(%d, %d, %d, %d)", + redFromColor(color), + greenFromColor(color), + blueFromColor(color), + alphaFromColor(color)]; +} + +static NSString *describeStops(const std::vector &colorStops) +{ + NSMutableArray *descriptions = [NSMutableArray new]; + for (const auto &colorStop : colorStops) { + [descriptions addObject:[NSString stringWithFormat:@"%@ %@", + describeColor(colorStop.color), + colorStop.position ? (id) @(*colorStop.position) : @"null"]]; + } + return [descriptions componentsJoinedByString:@", "]; +} + +#define RCTAssertColorStop(colorStop, expectedColor, expectedPosition) \ + do { \ + XCTAssertEqualObjects(describeColor((colorStop).color), describeColor(expectedColor)); \ + XCTAssertTrue((colorStop).position.has_value()); \ + XCTAssertEqualWithAccuracy((colorStop).position.value_or(-1), (expectedPosition), 1e-4); \ + } while (0) + +@interface RCTGradientUtilsTests : XCTestCase + +@end + +@implementation RCTGradientUtilsTests { + SharedColor _red; + SharedColor _green; + SharedColor _blue; + SharedColor _yellow; + SharedColor _transparent; +} + +- (void)setUp +{ + [super setUp]; + _red = rgba(255, 0, 0); + _green = rgba(0, 255, 0); + _blue = rgba(0, 0, 255); + _yellow = rgba(255, 255, 0); + _transparent = rgba(0, 0, 0, 0); +} + +- (std::vector)clip:(const std::vector &)colorStops +{ + return [RCTGradientUtils clipColorStops:colorStops end:1]; +} + +- (void)testClipColorStopsKeepsStopsInsideRange +{ + std::vector colorStops = {{_red, 0.1}, {_blue, 0.9}}; + + XCTAssertTrue([self clip:colorStops] == colorStops); +} + +- (void)testClipColorStopsInterpolatesAtBothEnds +{ + auto clipped = [self clip:{{_red, -0.5}, {_blue, 1.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(191, 0, 64), 0); + RCTAssertColorStop(clipped[1], rgba(64, 0, 191), 1); +} + +- (void)testClipColorStopsKeepsStopsBetweenClippedEnds +{ + auto clipped = [self clip:{{_red, -0.5}, {_yellow, 0.25}, {_blue, 1.5}}]; + + XCTAssertEqual(clipped.size(), 3, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(255, 170, 0), 0); + RCTAssertColorStop(clipped[1], _yellow, 0.25); + RCTAssertColorStop(clipped[2], rgba(102, 102, 153), 1); +} + +- (void)testClipColorStopsOutsideRangeBecomeSolidColor +{ + auto below = [self clip:{{_red, -0.8}, {_blue, -0.2}}]; + XCTAssertEqual(below.size(), 2, @"%@", describeStops(below)); + RCTAssertColorStop(below[0], _blue, 0); + RCTAssertColorStop(below[1], _blue, 1); + + auto above = [self clip:{{_red, 1.2}, {_blue, 1.8}}]; + XCTAssertEqual(above.size(), 2, @"%@", describeStops(above)); + RCTAssertColorStop(above[0], _red, 0); + RCTAssertColorStop(above[1], _red, 1); +} + +- (void)testClipColorStopsAddsSecondStopWhenOnlyOneRemains +{ + auto clipped = [self clip:{{_red, -0.1}, {_blue, 0}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _blue, 0); + RCTAssertColorStop(clipped[1], _blue, 1); +} + +- (void)testClipColorStopsWithCustomEndOnlyClipsBelowZero +{ + auto clipped = [RCTGradientUtils clipColorStops:{{_red, -0.5}, {_blue, 1.5}} end:1.5]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(191, 0, 64), 0); + RCTAssertColorStop(clipped[1], _blue, 1.5); +} + +- (void)testClipColorStopsInterpolatesWithPremultipliedAlpha +{ + auto clipped = [self clip:{{_red, -0.5}, {_transparent, 1.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(255, 0, 0, 191), 0); + RCTAssertColorStop(clipped[1], rgba(255, 0, 0, 64), 1); +} + +- (void)testClipColorStopsKeepsStopsOnRangeBoundaries +{ + auto clipped = [self clip:{{_red, -0.5}, {_green, 0}, {_blue, 1}, {_yellow, 1.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _green, 0); + RCTAssertColorStop(clipped[1], _blue, 1); +} + +- (void)testClipColorStopsInterpolatesBetweenStopsAroundBoundary +{ + auto clipped = [self clip:{{_red, -1}, {_green, -0.5}, {_blue, 0.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(0, 128, 128), 0); + RCTAssertColorStop(clipped[1], _blue, 0.5); +} + +- (void)testClipColorStopsWithNoStopInsideRange +{ + auto clipped = [self clip:{{_red, -1}, {_green, -0.5}, {_blue, 2}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(0, 204, 51), 0); + RCTAssertColorStop(clipped[1], rgba(0, 102, 153), 1); +} + +- (void)testClipColorStopsInterpolatesBetweenSemiTransparentColors +{ + auto clipped = [self clip:{{rgba(255, 0, 0, 128), -0.5}, {_blue, 1.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(153, 0, 102, 160), 0); + RCTAssertColorStop(clipped[1], rgba(37, 0, 218, 223), 1); +} + +- (void)testTransitionHintsAreExpandedBeforeClipping +{ + auto fixed = [RCTGradientUtils getFixedColorStops:{{_red, ValueUnit(-30, UnitType::Percent)}, + {SharedColor(), ValueUnit(0, UnitType::Percent)}, + {_blue, ValueUnit(65, UnitType::Percent)}} + gradientLineLength:100]; + auto clipped = [self clip:fixed]; + + // The hint is an even mix, 127.5, which RCTInterpolateColorInRange rounds up. + RCTAssertColorStop(clipped.front(), rgba(128, 0, 128), 0); + for (const auto &colorStop : clipped) { + XCTAssertGreaterThanOrEqual(colorStop.position.value_or(-1), 0, @"%@", describeStops(clipped)); + } + RCTAssertColorStop(clipped.back(), _blue, 0.65); +} + +// From WPT: linear-gradient(green -50%, blue -50%) and radial-gradient(green -50%, blue -50%). +// https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-linear.html +// https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-radial.html +- (void)testCoincidentStopsBelowRangeRenderLastColor +{ + auto fixed = [RCTGradientUtils + getFixedColorStops:{{_green, ValueUnit(-50, UnitType::Percent)}, {_blue, ValueUnit(-50, UnitType::Percent)}} + gradientLineLength:100]; + auto clipped = [self clip:fixed]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _blue, 0); + RCTAssertColorStop(clipped[1], _blue, 1); +} + +// From WPT: linear-gradient(blue 150%, red 150%). +// https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-linear-2.html +- (void)testCoincidentStopsAboveRangeRenderFirstColor +{ + auto fixed = [RCTGradientUtils + getFixedColorStops:{{_blue, ValueUnit(150, UnitType::Percent)}, {_red, ValueUnit(150, UnitType::Percent)}} + gradientLineLength:100]; + auto clipped = [self clip:fixed]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _blue, 0); + RCTAssertColorStop(clipped[1], _blue, 1); +} + +// From WPT: radial-gradient(blue 150%, red 150%). Radial gradients keep stops past the ending +// shape, and grow the radius to fit them. +// https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-radial-2.html +- (void)testClipColorStopsKeepsStopsUpToCustomEnd +{ + std::vector colorStops = {{_blue, 1.5}, {_red, 1.5}}; + + XCTAssertTrue([RCTGradientUtils clipColorStops:colorStops end:1.5] == colorStops); +} + +- (void)testLengthPositionsOnZeroLengthGradientLineResolveToZero +{ + auto fixed = [RCTGradientUtils + getFixedColorStops:{{_red, ValueUnit(10, UnitType::Point)}, {_blue, ValueUnit(20, UnitType::Point)}} + gradientLineLength:0]; + + XCTAssertEqual(fixed.size(), 2, @"%@", describeStops(fixed)); + RCTAssertColorStop(fixed[0], _red, 0); + RCTAssertColorStop(fixed[1], _blue, 0); +} + +@end diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt index f3de07162648..f95f8439ce38 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt @@ -367,7 +367,9 @@ class ColorStopTest { assertThat(clipped.last().position).isEqualTo(0.65f) } - // Mirrors WPT css/css-images/normalization-linear.html and normalization-radial.html. + // From WPT: linear-gradient(green -50%, blue -50%) and radial-gradient(green -50%, blue -50%). + // https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-linear.html + // https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-radial.html @Test fun testCoincidentStopsBelowRangeRenderLastColor() { val fixed = @@ -383,7 +385,8 @@ class ColorStopTest { .containsExactly(Color.BLUE, Color.BLUE) } - // Mirrors WPT css/css-images/normalization-linear-2.html. + // From WPT: linear-gradient(blue 150%, red 150%). + // https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-linear-2.html @Test fun testCoincidentStopsAboveRangeRenderFirstColor() { val fixed = @@ -399,8 +402,9 @@ class ColorStopTest { .containsExactly(Color.BLUE, Color.BLUE) } - // Mirrors WPT css/css-images/normalization-radial-2.html: radial gradients keep stops past the - // ending shape, and grow the radius to fit them. + // From WPT: radial-gradient(blue 150%, red 150%). Radial gradients keep stops past the ending + // shape, and grow the radius to fit them. + // https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-radial-2.html @Test fun testClipColorStopsKeepsStopsUpToCustomEnd() { val colorStops = From c28b7f7665af12c06dd01064b195a063da0a63a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Tue, 3 Nov 2026 11:17:22 +0530 Subject: [PATCH 3/5] Quote the CSS spec on out-of-range gradient color stops --- .../React/Fabric/Utils/RCTGradientUtils.mm | 7 ++++--- .../com/facebook/react/uimanager/style/ColorStop.kt | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm index 446bf254ff55..c4e9844cdaca 100644 --- a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm +++ b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm @@ -361,9 +361,10 @@ @implementation RCTGradientUtils return processColorTransitionHints(fixedColorStops); } -// Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line -// Color stops outside [0, end] still affect the colors inside it through interpolation, but are never painted -// directly, so they are replaced by stops at the range boundaries, colored as the gradient would be at that point. +// Spec: https://drafts.csswg.org/css-images-4/#conic-color-stops +// Color stops outside [0, end] are "never directly consulted for rendering", but "can affect the color of +// color-stops within the rendered region through interpolation". So they are replaced by stops at the range +// boundaries, colored as the gradient would be at that point. + (std::vector)clipColorStops:(const std::vector &)colorStops end:(Float)end { if (colorStops.empty() || diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt index 220b81a34db4..b16d1f279a56 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt @@ -142,12 +142,13 @@ internal object ColorStopUtils { /** * Replaces color stops outside `[0, end]` with stops at the range boundaries. * - * Stops outside the range still affect the colors inside it through interpolation, but are never - * painted directly. Android's gradient shaders pin them to the range instead, so each boundary - * gets the color the gradient would have at that point. + * Per the spec, stops outside the range are "never directly consulted for rendering", but "can + * affect the color of color-stops within the rendered region through interpolation". Android's + * gradient shaders pin them to the range instead, so each boundary gets the color the gradient + * would have at that point. * - * @see CSS Images Module - * Level 4 + * @see CSS Images Module Level + * 4 */ fun clipColorStops( colorStops: List, From 1bd673716734470964b3cf407bb922937880d767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Tue, 3 Nov 2026 14:26:07 +0530 Subject: [PATCH 4/5] =?UTF-8?q?Add=20RNTester=20examples=20for=20gradient?= =?UTF-8?q?=20color=20stops=20outside=200=E2=80=93100%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LinearGradient/LinearGradientExample.js | 15 +++++++++++++++ .../RadialGradient/RadialGradientExample.js | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js index 9c68fa3ed865..244c5f0531be 100644 --- a/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js +++ b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js @@ -302,4 +302,19 @@ exports.examples = [ ); }, }, + { + title: 'Color stops outside 0–100%', + name: 'out-of-range-color-stops', + render(): React.Node { + return ( + + ); + }, + }, ] as Array; diff --git a/packages/rn-tester/js/examples/RadialGradient/RadialGradientExample.js b/packages/rn-tester/js/examples/RadialGradient/RadialGradientExample.js index ebea541a0766..5fedc19207a4 100644 --- a/packages/rn-tester/js/examples/RadialGradient/RadialGradientExample.js +++ b/packages/rn-tester/js/examples/RadialGradient/RadialGradientExample.js @@ -286,4 +286,21 @@ exports.examples = [ ); }, }, + { + title: 'Color stops outside 0–100%', + name: 'out-of-range-color-stops', + render(): React.Node { + return ( + + ); + }, + }, ] as Array; From a795489991c81ff4fa1fe540e9a4c89907177d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Tue, 3 Nov 2026 17:29:23 +0530 Subject: [PATCH 5/5] Reject non-finite gradient color stop positions and clamp them natively --- .../__tests__/processBackgroundImage-itest.js | 14 ++++++++++ .../StyleSheet/processBackgroundImage.js | 26 ++++++++++++------- .../React/Fabric/Utils/RCTGradientUtils.mm | 11 ++++++-- .../Tests/Fabric/RCTGradientUtilsTests.mm | 15 +++++++++++ .../react/uimanager/style/ColorStop.kt | 3 ++- .../react/uimanager/style/ColorStopTest.kt | 20 ++++++++++++++ 6 files changed, 76 insertions(+), 13 deletions(-) diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js index 4eb927aa9e4e..289ebbe9c78c 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js @@ -353,6 +353,20 @@ describe('processBackgroundImage', () => { expect(result).toEqual([]); }); + it('should return empty array for non-finite color stop positions', () => { + expect(processBackgroundImage('linear-gradient(red NaN%, blue)')).toEqual( + [], + ); + expect( + processBackgroundImage([ + { + type: 'linear-gradient' as const, + colorStops: [{color: 'red', positions: ['NaN%']}, {color: 'blue'}], + }, + ]), + ).toEqual([]); + }); + it('should process unitless zero color stop position', () => { const input = 'linear-gradient(red 0, blue 100%)'; const result = processBackgroundImage(input); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index e7985e2737a6..04caa08599c5 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -191,6 +191,17 @@ export default function processBackgroundImage( return result; } +function isValidColorStopPosition(position: unknown): boolean { + if (typeof position === 'number') { + return Number.isFinite(position); + } + return ( + typeof position === 'string' && + position.endsWith('%') && + Number.isFinite(parseFloat(position)) + ); +} + function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ color: ColorStopColor, position: ColorStopPosition, @@ -210,10 +221,7 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ positions.length === 1 ) { const position = positions[0]; - if ( - typeof position === 'number' || - (typeof position === 'string' && position.endsWith('%')) - ) { + if (isValidColorStopPosition(position)) { processedColorStops.push({ color: null, position, @@ -230,10 +238,7 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ } if (positions != null && positions.length > 0) { for (const position of positions) { - if ( - typeof position === 'number' || - (typeof position === 'string' && position.endsWith('%')) - ) { + if (isValidColorStopPosition(position)) { processedColorStops.push({ color: processedColor, position, @@ -810,10 +815,11 @@ function getPositionFromCSSValue(position: string) { } if (position.endsWith('px')) { - return parseFloat(position); + const value = parseFloat(position); + return Number.isFinite(value) ? value : null; } - if (position.endsWith('%')) { + if (isValidColorStopPosition(position)) { return position; } } diff --git a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm index c4e9844cdaca..78c1f89f2a90 100644 --- a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm +++ b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm @@ -11,6 +11,7 @@ #import #include #include +#include #include #import @@ -145,14 +146,20 @@ CGSize calculateMultipliers(CGSize bounds) } // namespace +// Like Blink, clamp to finite values so that interpolation never divides infinity by infinity. +static Float clampToFinite(Float position) +{ + return std::clamp(position, -std::numeric_limits::max(), std::numeric_limits::max()); +} + static std::optional resolveColorStopPosition(ValueUnit position, CGFloat gradientLineLength) { if (position.unit == UnitType::Point) { - return gradientLineLength > 0 ? position.resolve(0.0f) / gradientLineLength : 0; + return clampToFinite(gradientLineLength > 0 ? position.resolve(0.0f) / gradientLineLength : 0); } if (position.unit == UnitType::Percent) { - return position.resolve(1.0f); + return clampToFinite(position.resolve(1.0f)); } return std::nullopt; diff --git a/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm b/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm index e6d569341c5a..4bd173fcd067 100644 --- a/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm +++ b/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm @@ -177,6 +177,21 @@ - (void)testClipColorStopsInterpolatesBetweenSemiTransparentColors RCTAssertColorStop(clipped[1], rgba(37, 0, 218, 223), 1); } +- (void)testInfinitePositionsAreClampedBeforeClipping +{ + auto fixed = [RCTGradientUtils + getFixedColorStops:{{_red, ValueUnit(-INFINITY, UnitType::Percent)}, {_blue, ValueUnit(50, UnitType::Percent)}} + gradientLineLength:100]; + auto clipped = [self clip:fixed]; + + for (const auto &colorStop : fixed) { + XCTAssertTrue(std::isfinite(colorStop.position.value_or(NAN)), @"%@", describeStops(fixed)); + } + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _blue, 0); + RCTAssertColorStop(clipped[1], _blue, 0.5); +} + - (void)testTransitionHintsAreExpandedBeforeClipping { auto fixed = [RCTGradientUtils getFixedColorStops:{{_red, ValueUnit(-30, UnitType::Percent)}, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt index b16d1f279a56..11cd854cefb5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt @@ -332,6 +332,7 @@ internal object ColorStopUtils { return null } + // Like Blink, clamp to finite values so that interpolation never divides infinity by infinity. return when (position.type) { LengthPercentageType.POINT -> if (gradientLineLength > 0f) { @@ -341,6 +342,6 @@ internal object ColorStopUtils { } LengthPercentageType.PERCENT -> position.resolve(1f) - } + }.coerceIn(-Float.MAX_VALUE, Float.MAX_VALUE) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt index f95f8439ce38..18f78f351eb7 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt @@ -347,6 +347,26 @@ class ColorStopTest { assertThat(clipped[1].color).isEqualTo(Color.argb(223, 37, 0, 218)) } + @Test + fun testInfinitePositionsAreClampedBeforeClipping() { + val fixed = + ColorStopUtils.getFixedColorStops( + listOf( + ColorStop( + Color.RED, + LengthPercentage(Float.NEGATIVE_INFINITY, LengthPercentageType.PERCENT), + ), + ColorStop(Color.BLUE, LengthPercentage(50f, LengthPercentageType.PERCENT)), + ), + 100f, + ) + val clipped = ColorStopUtils.clipColorStops(fixed) + + assertThat(fixed.map { it.position }).allMatch { it != null && it.isFinite() } + assertThat(clipped.map { it.color }).containsExactly(Color.BLUE, Color.BLUE) + assertThat(clipped.map { it.position }).containsExactly(0f, 0.5f) + } + @Test fun testTransitionHintsAreExpandedBeforeClipping() { val fixed =