From 88974ca0d3707484b010c1132fbe3fefb03b503a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Fri, 16 Oct 2026 02:15:42 -0700 Subject: [PATCH 1/2] Fix Image payloads that Props 2.0 sends wrong or drops (#59077) Summary: With Props 2.0 on Android (`enableExclusivePropsUpdateAndroid` and the related flags), `ReactImageManager` receives the payload from `ImageProps::getDiffProps`. Some Image props come out wrong: - Several sources or `srcSet`: crash with `NoSuchKeyException: width`, because each source's size is nested under `size`. - `headers`: never sent, and not cleared when the new source has none. - A source with the same URI but different headers, cache, size or scale: not sent. - A removed `tintColor`: sent as `0`, so the image disappears. This PR sends each of them the way the Java side reads them. Nothing changes with the flags off. ## Changelog: [ANDROID] [FIXED] - Fix Image sources, headers and a removed tintColor with Props 2.0 Pull Request resolved: https://github.com/react/react-native/pull/59077 Test Plan: - New `ImagePropsDiffTest`. It uses a small helper, `PropsPayloads.h`, that checks each Props 2.0 payload against what Props 1.5 sends for the same prop updates. Each test fails on main. Built with the NDK and run on an Android emulator (there is no open-source gtest target). - RNTester on an Android emulator with the Props 2.0 flags forced on. **Image example: `srcSet` and several sources**
BeforeAfter
https://github.com/user-attachments/assets/483306f2-f308-401e-8f4c-809337ec2292 https://github.com/user-attachments/assets/cf978447-071e-4537-8d3f-31e663f42bc0
**Remove `tintColor` (Playground screen below)**
BeforeAfter
https://github.com/user-attachments/assets/349be881-8de6-4731-b01b-362fabd0278f https://github.com/user-attachments/assets/04a7e35a-a64a-4317-8d91-cc30a7534b0d
RNTesterPlayground.js: paste into packages/rn-tester/js/examples/Playground/RNTesterPlayground.js and open Playground. ```js /** * 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. * * flow strict-local * format */ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import * as React from 'react'; import {useState} from 'react'; import {Button, Image, StyleSheet, Text, View} from 'react-native'; function Tint() { const [on, setOn] = useState(true); return (
Differential Revision: D125438476 Pulled By: bartlomiejbloniarz --- .../react/views/image/ReactImageView.kt | 6 +- .../views/image/ReactImagePropertyTest.kt | 29 ++++ .../renderer/components/image/ImageProps.cpp | 35 ++++- .../image/tests/ImagePropsDiffTest.cpp | 93 +++++++++++++ .../react/renderer/imagemanager/primitives.h | 6 +- .../react/test_utils/PropsPayloads.h | 131 ++++++++++++++++++ 6 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 packages/react-native/ReactCommon/react/renderer/components/image/tests/ImagePropsDiffTest.cpp create mode 100644 packages/react-native/ReactCommon/react/test_utils/PropsPayloads.h diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt index 3f413faaf7c0..b894ac8129f6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt @@ -369,8 +369,12 @@ public class ReactImageView( // no worth marking as dirty if it already rendered. } + /** Sets the request headers. A change rebuilds the image request on the next update. */ public fun setHeaders(headers: ReadableMap?) { - this.headers = headers + if (this.headers != headers) { + this.headers = headers + isDirty = true + } } // Disable rasterizing to offscreen layer in order to preserve background effects like box-shadow diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/image/ReactImagePropertyTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/image/ReactImagePropertyTest.kt index c3b78c3f3834..b69f015595e0 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/image/ReactImagePropertyTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/image/ReactImagePropertyTest.kt @@ -15,7 +15,9 @@ import android.graphics.Color import android.util.DisplayMetrics import com.facebook.common.logging.FLog import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.drawee.backends.pipeline.PipelineDraweeControllerBuilder import com.facebook.drawee.drawable.ScalingUtils +import com.facebook.imagepipeline.request.ImageRequest import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.CatalystInstance @@ -23,6 +25,7 @@ import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance import com.facebook.react.common.ReactConstants import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.modules.fresco.ReactNetworkImageRequest import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.react.uimanager.LengthPercentage @@ -40,12 +43,15 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.MockedStatic +import org.mockito.Mockito.RETURNS_SELF import org.mockito.Mockito.anyString import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.reset +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @@ -281,4 +287,27 @@ class ReactImagePropertyTest { viewManager.setHeaders(mockView, headers) verify(mockView).setHeaders(headers) } + + @Test + fun testHeadersChangeRebuildsImageRequest() { + val controllerBuilder = mock(defaultAnswer = RETURNS_SELF) + val viewManager = ReactImageManager(controllerBuilder) + val view = viewManager.createViewInstance(themeContext) + val sources = Arguments.createArray() + sources.pushMap(JavaOnlyMap.of("uri", "https://example.com/image.png")) + viewManager.setSource(view, sources) + viewManager.setHeaders(view, JavaOnlyMap.of("Authorization", "token-1")) + view.maybeUpdateView() + + viewManager.setHeaders(view, JavaOnlyMap.of("Authorization", "token-2")) + view.maybeUpdateView() + viewManager.setHeaders(view, JavaOnlyMap.of("Authorization", "token-2")) + view.maybeUpdateView() + + val requests = argumentCaptor() + verify(controllerBuilder, times(2)).setImageRequest(requests.capture()) + assertThat(requests.firstValue.sourceUri).isEqualTo(requests.secondValue.sourceUri) + assertThat((requests.secondValue as ReactNetworkImageRequest).headers) + .isEqualTo(JavaOnlyMap.of("Authorization", "token-2")) + } } diff --git a/packages/react-native/ReactCommon/react/renderer/components/image/ImageProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/image/ImageProps.cpp index 8cb669ebf127..2785fa720679 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/image/ImageProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/image/ImageProps.cpp @@ -12,6 +12,9 @@ #include #include +#include +#include + namespace facebook::react { ImageProps::ImageProps( @@ -145,6 +148,30 @@ static folly::dynamic convertEdgeInsets(const EdgeInsets& edgeInsets) { return edgeInsetsResult; } +// ImageSource::operator== compares only the request identity: type and URI. +static bool hasSamePayload(const ImageSource& lhs, const ImageSource& rhs) { + return std::tie( + lhs.type, + lhs.uri, + lhs.bundle, + lhs.scale, + lhs.size, + lhs.body, + lhs.method, + lhs.cache, + lhs.headers) == + std::tie( + rhs.type, + rhs.uri, + rhs.bundle, + rhs.scale, + rhs.size, + rhs.body, + rhs.method, + rhs.cache, + rhs.headers); +} + ComponentName ImageProps::getDiffPropsImplementationTarget() const { return "Image"; } @@ -158,11 +185,14 @@ folly::dynamic ImageProps::getDiffProps(const Props* prevProps) const { folly::dynamic result = ViewProps::getDiffProps(oldProps); - if (sources != oldProps->sources) { + if (!std::ranges::equal(sources, oldProps->sources, hasSamePayload)) { auto sourcesArray = folly::dynamic::array(); for (const auto& source : sources) { sourcesArray.push_back(toDynamic(source)); } + if (!sources.empty()) { + result["headers"] = sourcesArray[0]["headers"]; + } result["source"] = sourcesArray; } @@ -221,7 +251,8 @@ folly::dynamic ImageProps::getDiffProps(const Props* prevProps) const { SharedColor prevTintColorValue = oldProps->tintColor.value_or(SharedColor{}); if (tintColorValue != prevTintColorValue) { - result["tintColor"] = *tintColorValue; + result["tintColor"] = tintColorValue ? folly::dynamic(*tintColorValue) + : folly::dynamic(nullptr); } } diff --git a/packages/react-native/ReactCommon/react/renderer/components/image/tests/ImagePropsDiffTest.cpp b/packages/react-native/ReactCommon/react/renderer/components/image/tests/ImagePropsDiffTest.cpp new file mode 100644 index 000000000000..22720793e0b3 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/image/tests/ImagePropsDiffTest.cpp @@ -0,0 +1,93 @@ +/* + * 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. + */ + +#include + +#include +#include + +#ifdef RN_SERIALIZABLE_STATE + +namespace facebook::react { + +namespace { + +using folly::dynamic; + +// Props 2.0 serializes every field of a source, JS only the ones it sets. +void expectPayloads(const std::vector& commits) { + expectPayloadsMatchRawProps(commits, {"source"}); +} + +} // namespace + +// ReactImageView.setSource reads width and height at the top level of each +// source when there are several, and ReactImageManager reads the request +// headers from the top-level headers prop, which JS derives from the first +// source. +TEST(ImagePropsDiffTest, multipleSourcesCarrySizesAndHeaders) { + dynamic headers = dynamic::object("Authorization", "token"); + expectPayloads({{dynamic::object( + "source", + dynamic::array( + dynamic::object("uri", "https://a")("width", 10)("height", 20)( + "headers", headers), + dynamic::object("uri", "https://b")("width", 30)("height", 40)))( + "headers", headers)}}); +} + +// ReactImageManager ignores a null headers prop, so only an empty map clears +// the headers of the previous source. +TEST(ImagePropsDiffTest, clearsHeadersWhenTheNewSourceHasNone) { + dynamic headers = dynamic::object("Authorization", "old"); + expectPayloads({ + {dynamic::object( + "source", + dynamic::array( + dynamic::object("uri", "https://a")("headers", headers)))( + "headers", headers)}, + {dynamic::object( + "source", dynamic::array(dynamic::object("uri", "https://b")))( + "headers", nullptr), + dynamic::object("headers", dynamic::object())}, + }); +} + +// ReactImageManager clears the tint only for null; 0 is a transparent SRC_IN +// tint that hides the image. +TEST(ImagePropsDiffTest, removingTintColorEmitsNull) { + expectPayloads({ + {dynamic::object("tintColor", static_cast(0xFFFF0000))}, + {dynamic::object("tintColor", nullptr)}, + }); +} + +// ReactImageView reads the cache policy and the sizes of each source, and +// ReactImageManager the headers; a source with the same URI can change them. +TEST(ImagePropsDiffTest, emitsASameUriSourceWhenItsOtherFieldsChange) { + dynamic headers = dynamic::object("Authorization", "old"); + dynamic source = dynamic::object("uri", "https://a")("width", 10)( + "height", 20)("scale", 1)("cache", "default")("headers", headers); + std::vector commits{ + {dynamic::object("source", dynamic::array(source))("headers", headers)}}; + dynamic changes = dynamic::object("cache", "reload")("width", 30)("scale", 2); + for (const auto& [field, value] : changes.items()) { + source[field] = value; + // Props 2.0 sends the first source's headers whenever the sources change. + commits.push_back( + {dynamic::object("source", dynamic::array(source)), + dynamic::object("headers", headers)}); + } + source["headers"] = dynamic::object("Authorization", "new"); + commits.push_back({dynamic::object("source", dynamic::array(source))( + "headers", source["headers"])}); + expectPayloads(commits); +} + +} // namespace facebook::react + +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/imagemanager/primitives.h b/packages/react-native/ReactCommon/react/renderer/imagemanager/primitives.h index 86756373d6da..17593ca52f78 100644 --- a/packages/react-native/ReactCommon/react/renderer/imagemanager/primitives.h +++ b/packages/react-native/ReactCommon/react/renderer/imagemanager/primitives.h @@ -62,10 +62,8 @@ class ImageSource { imageSourceResult["bundle"] = bundle; imageSourceResult["scale"] = scale; - folly::dynamic sizeResult = folly::dynamic::object(); - sizeResult["width"] = size.width; - sizeResult["height"] = size.height; - imageSourceResult["size"] = sizeResult; + imageSourceResult["width"] = size.width; + imageSourceResult["height"] = size.height; imageSourceResult["body"] = body; imageSourceResult["method"] = method; diff --git a/packages/react-native/ReactCommon/react/test_utils/PropsPayloads.h b/packages/react-native/ReactCommon/react/test_utils/PropsPayloads.h new file mode 100644 index 000000000000..88da44225401 --- /dev/null +++ b/packages/react-native/ReactCommon/react/test_utils/PropsPayloads.h @@ -0,0 +1,131 @@ +/* + * 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. + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#ifdef RN_SERIALIZABLE_STATE + +namespace facebook::react { + +/* + * One commit: the props JS sends (a removed prop is null), and the Props 2.0 + * values that intentionally differ from what Props 1.5 sends. + */ +struct PropsCommit { + folly::dynamic props; + folly::dynamic intendedDifferences = folly::dynamic::object(); +}; + +namespace detail { + +inline bool payloadValuesMatch(const folly::dynamic &actual, const folly::dynamic &expected) +{ + if (actual.isNumber() && expected.isNumber()) { + // Props 2.0 stores floats, so 0.998 comes back as 0.99800002574... + auto tolerance = 1e-6 * std::max(1.0, std::abs(expected.asDouble())); + return std::abs(actual.asDouble() - expected.asDouble()) <= tolerance; + } + if (actual.isArray() && expected.isArray()) { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin(), payloadValuesMatch); + } + if (actual.isObject() && expected.isObject()) { + return actual.size() == expected.size() && std::ranges::all_of(expected.items(), [&](const auto &item) { + return actual.count(item.first) != 0 && payloadValuesMatch(actual[item.first], item.second); + }); + } + return actual == expected; +} + +// Each element carries the fields of the expected element, and may add more. +inline bool elementsContainFields(const folly::dynamic &actual, const folly::dynamic &expected) +{ + if (!actual.isArray() || !expected.isArray() || actual.size() != expected.size()) { + return payloadValuesMatch(actual, expected); + } + for (size_t i = 0; i < expected.size(); i++) { + for (const auto &[field, value] : expected[i].items()) { + if (actual[i].count(field) == 0 || !payloadValuesMatch(actual[i][field], value)) { + return false; + } + } + } + return true; +} + +} // namespace detail + +/* + * Applies the commits in order and checks that each Props 2.0 payload + * (`getDiffProps`) has the keys, nulls and values of the Props 1.5 payload + * (accumulated raw props), apart from the commit's intended differences. The + * elements of the arrays under `keysWithAddedFields` may carry fields that JS + * does not send. + */ +template +void expectPayloadsMatchRawProps( + const std::vector &commits, + const std::set &keysWithAddedFields = {}) +{ + ContextContainer contextContainer{}; + PropsParserContext parserContext{-1, contextContainer}; + + auto previous = std::make_shared(); + folly::dynamic previousRawProps = folly::dynamic::object(); + for (size_t i = 0; i < commits.size(); i++) { + const auto &commit = commits[i]; + auto rawProps = RawProps(commit.props); + auto parser = RawPropsParser(); + parser.template prepare(); + rawProps.parse(parser); + auto props = std::make_shared(parserContext, *previous, rawProps); + auto accumulatedRawProps = mergeDynamicProps(previousRawProps, commit.props, NullValueStrategy::Override); + + auto actual = props->getDiffProps(i == 0 ? nullptr : previous.get()); + auto expected = i == 0 ? accumulatedRawProps : diffDynamicProps(previousRawProps, accumulatedRawProps); + for (const auto &[key, value] : commit.intendedDifferences.items()) { + expected[key] = value; + } + + std::set keys; + for (const auto &payload : {actual, expected}) { + for (const auto &key : payload.keys()) { + keys.insert(key.asString()); + } + } + for (const auto &key : keys) { + bool matches = actual.count(key) != 0 && expected.count(key) != 0 && + (keysWithAddedFields.contains(key) ? detail::elementsContainFields(actual[key], expected[key]) + : detail::payloadValuesMatch(actual[key], expected[key])); + EXPECT_TRUE(matches) << "commit " << i << " " << commit.props << ", key '" << key + << "'\n Props 2.0: " << actual.getDefault(key, "(absent)") + << "\n expected: " << expected.getDefault(key, "(absent)"); + } + + previous = props; + previousRawProps = accumulatedRawProps; + } +} + +} // namespace facebook::react + +#endif From a7b55aa3e82ef201b08b3d5c613d1598c58f1407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Fri, 16 Oct 2026 02:43:59 -0700 Subject: [PATCH 2/2] Fix View style payloads that Props 2.0 sends wrong or drops (#59078) Summary: With Props 2.0 on Android (`enableExclusivePropsUpdateAndroid` and the related flags), `ReactViewManager` receives the payload from `HostPlatformViewProps::getDiffProps`. Some style props come out wrong: - `backgroundSize`, `backgroundPosition`, `backgroundRepeat`: never sent, so a tiled gradient fills the whole view. - A `perspective` transform: sent as `perspectiveX`, which Android ignores. - A removed side border width: sent as `0` instead of falling back to `borderWidth`. - `boxShadow` or `drop-shadow` without a color: transparent instead of black. - A removed `shadowColor`: sent as `0`, so the elevation shadow disappears. - A gradient color hint, as in `linear-gradient(red, 20%, blue)`: drawn as a transparent stop. This PR sends each of them the way the Java side reads them. Nothing changes with the flags off, except that the animation backend shares the transform code and now also sends `perspective`. ## Changelog: [ANDROID] [FIXED] - Fix View background, perspective, border width, shadow and gradient props with Props 2.0 Pull Request resolved: https://github.com/react/react-native/pull/59078 Test Plan: - New `ViewPropsDiffTest`, using the payload helper from https://github.com/react/react-native/issues/59077. Each test fails on main. Built with the NDK and run on an Android emulator (there is no open-source gtest target). - RNTester on an Android emulator with the Props 2.0 flags forced on: the BackgroundImage example and the Playground screen below. **Gradient with Background Repeat**
BeforeAfter
https://github.com/user-attachments/assets/6e337636-608d-4c06-996f-914ff005528d https://github.com/user-attachments/assets/6d346094-eff6-4b20-a1aa-693988c47048
**Flip a card with `perspective: 400`**
BeforeAfter
https://github.com/user-attachments/assets/9fcdcbc0-ec94-43be-aa95-31e8d5b813c3 https://github.com/user-attachments/assets/7942a58c-f7c1-4292-aa02-edadb31924d3
**Remove `borderLeftWidth` (`borderWidth: 8`)**
BeforeAfter
https://github.com/user-attachments/assets/5098d21a-2686-4d01-9120-07247832579e https://github.com/user-attachments/assets/758dc39e-230e-45ec-81d5-098f601183d6
**`boxShadow` and `drop-shadow` without a color**
BeforeAfter
https://github.com/user-attachments/assets/20fa2a78-240f-4e62-afa9-b0f687b1493c https://github.com/user-attachments/assets/2c6257f5-e978-4710-b0b9-b1fdb34f1052
**Remove `shadowColor` (`elevation: 24`)**
BeforeAfter
https://github.com/user-attachments/assets/e74d27b5-6c1c-45f2-be88-a27b6a5fb0cd https://github.com/user-attachments/assets/55593913-96ea-46e5-91b8-c1fd3cf53c43
**Add a 20% color hint**
BeforeAfter
https://github.com/user-attachments/assets/9f2c2b2a-8b39-4e0d-9ca6-5c972c5abf75 https://github.com/user-attachments/assets/b4ab4fa6-7d92-4705-af76-bae13b091bc4
RNTesterPlayground.js: paste into packages/rn-tester/js/examples/Playground/RNTesterPlayground.js and open Playground. ```js /** * 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. * * flow strict-local * format */ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import * as React from 'react'; import {useState} from 'react'; import {Button, StyleSheet, Text, View} from 'react-native'; function Perspective() { const [on, setOn] = useState(false); return (
Differential Revision: D125438461 Pulled By: bartlomiejbloniarz --- .../components/view/HostPlatformViewProps.cpp | 67 ++++++++++- .../view/tests/ViewPropsDiffTest.cpp | 108 ++++++++++++++++++ .../react/renderer/graphics/BoxShadow.h | 4 +- .../react/renderer/graphics/ColorStop.cpp | 2 +- .../react/renderer/graphics/Filter.h | 4 +- .../react/renderer/graphics/TransformUtils.h | 2 +- 6 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp index 5c035510fe1c..d2417698a5f6 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp @@ -159,7 +159,7 @@ static void updateBorderWidthPropValue( if (newValue.has_value()) { result[propName] = newValue.value(); } else { - result[propName] = NULL; + result[propName] = nullptr; } } } @@ -449,6 +449,56 @@ inline static void updateAccessibilityStateProp( result["accessibilityState"] = resultState; } +static folly::dynamic toDynamic( + const std::variant& lengthPercentageOrAuto) { + const auto* valueUnit = std::get_if(&lengthPercentageOrAuto); + return valueUnit != nullptr ? valueUnit->toDynamic() : "auto"; +} + +static folly::dynamic toDynamic(const BackgroundSize& backgroundSize) { + if (const auto* keyword = + std::get_if(&backgroundSize)) { + return *keyword == BackgroundSizeKeyword::Cover ? "cover" : "contain"; + } + const auto& size = std::get(backgroundSize); + return folly::dynamic::object("x", toDynamic(size.x))("y", toDynamic(size.y)); +} + +// JS sends one edge per axis, but the parser keeps the default top and left +// next to a bottom or right. +static folly::dynamic toDynamic(const BackgroundPosition& backgroundPosition) { + folly::dynamic result = folly::dynamic::object(); + if (backgroundPosition.bottom.has_value()) { + result["bottom"] = backgroundPosition.bottom->toDynamic(); + } else if (backgroundPosition.top.has_value()) { + result["top"] = backgroundPosition.top->toDynamic(); + } + if (backgroundPosition.right.has_value()) { + result["right"] = backgroundPosition.right->toDynamic(); + } else if (backgroundPosition.left.has_value()) { + result["left"] = backgroundPosition.left->toDynamic(); + } + return result; +} + +static const char* toString(BackgroundRepeatStyle backgroundRepeatStyle) { + switch (backgroundRepeatStyle) { + case BackgroundRepeatStyle::Repeat: + return "repeat"; + case BackgroundRepeatStyle::Space: + return "space"; + case BackgroundRepeatStyle::Round: + return "round"; + case BackgroundRepeatStyle::NoRepeat: + return "no-repeat"; + } +} + +static folly::dynamic toDynamic(const BackgroundRepeat& backgroundRepeat) { + return folly::dynamic::object("x", toString(backgroundRepeat.x))( + "y", toString(backgroundRepeat.y)); +} + ComponentName HostPlatformViewProps::getDiffPropsImplementationTarget() const { return "View"; } @@ -528,7 +578,8 @@ folly::dynamic HostPlatformViewProps::getDiffProps( } if (shadowColor != oldProps->shadowColor) { - result["shadowColor"] = *shadowColor; + result["shadowColor"] = + shadowColor ? folly::dynamic(*shadowColor) : folly::dynamic(nullptr); } if (shadowOpacity != oldProps->shadowOpacity) { @@ -580,6 +631,18 @@ folly::dynamic HostPlatformViewProps::getDiffProps( result["backgroundImage"] = toDynamic(backgroundImage); } + if (backgroundSize != oldProps->backgroundSize) { + result["backgroundSize"] = toDynamic(backgroundSize); + } + + if (backgroundPosition != oldProps->backgroundPosition) { + result["backgroundPosition"] = toDynamic(backgroundPosition); + } + + if (backgroundRepeat != oldProps->backgroundRepeat) { + result["backgroundRepeat"] = toDynamic(backgroundRepeat); + } + if (mixBlendMode != oldProps->mixBlendMode) { result["mixBlendMode"] = toString(mixBlendMode); } diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp new file mode 100644 index 000000000000..0c328359c855 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp @@ -0,0 +1,108 @@ +/* + * 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. + */ + +#include + +#include +#include + +#ifdef RN_SERIALIZABLE_STATE + +namespace facebook::react { + +namespace { + +using folly::dynamic; + +void expectPayloads(const std::vector& commits) { + expectPayloadsMatchRawProps(commits); +} + +const auto kRed = static_cast(0xFFFF0000); + +} // namespace + +// ReactViewManager has setters for all three; with +// enableExclusivePropsUpdateAndroid there is no rawProps fallback. +TEST(ViewPropsDiffTest, emitsBackgroundSizePositionAndRepeat) { + expectPayloads({{dynamic::object( + "backgroundSize", + dynamic::array( + dynamic::object("x", "50%")("y", "auto"), + dynamic::object("x", 20)("y", "25%")))( + "backgroundPosition", + dynamic::array( + dynamic::object("top", "10%")("left", 5), + dynamic::object("bottom", 0)("right", "20%")))( + "backgroundRepeat", + dynamic::array(dynamic::object("x", "no-repeat")("y", "space")))}}); +} + +// The Android transform parser knows `perspective`, not `perspectiveX`. +TEST(ViewPropsDiffTest, emitsPerspectiveUnderItsOwnName) { + expectPayloads({{dynamic::object( + "transform", dynamic::array(dynamic::object("perspective", 850)))}}); +} + +// ReactViewManager resets a side to `borderWidth` only for null (NaN); 0 keeps +// the side at width 0. +TEST(ViewPropsDiffTest, removingASideBorderWidthEmitsNull) { + expectPayloads({ + {dynamic::object("borderWidth", 4)("borderLeftWidth", 12)}, + {dynamic::object("borderLeftWidth", nullptr)}, + }); +} + +// BoxShadow.parse defaults an absent color to black; 0 is transparent. +TEST(ViewPropsDiffTest, boxShadowWithoutColorOmitsColor) { + expectPayloads({{dynamic::object( + "boxShadow", + dynamic::array( + dynamic::object("offsetX", 0)("offsetY", 4)("blurRadius", 8)( + "spreadDistance", 0)("inset", false)))}}); +} + +// FilterHelper defaults an absent drop shadow color to black; 0 is +// transparent. +TEST(ViewPropsDiffTest, dropShadowWithoutColorOmitsColor) { + expectPayloads({{dynamic::object( + "filter", + dynamic::array( + dynamic::object( + "dropShadow", + dynamic::object("offsetX", 0)("offsetY", 4)( + "standardDeviation", 4))))}}); +} + +// BaseViewManager.setShadowColor resets to black for null; 0 makes the +// elevation shadow transparent. +TEST(ViewPropsDiffTest, removingShadowColorEmitsNull) { + expectPayloads({ + {dynamic::object("elevation", 8)("shadowColor", kRed)}, + {dynamic::object("shadowColor", nullptr)}, + }); +} + +// The Android gradients treat a stop with a null color as a transition hint; +// 0 is a transparent stop. +TEST(ViewPropsDiffTest, gradientColorHintEmitsNullColor) { + expectPayloads({{dynamic::object( + "backgroundImage", + dynamic::array( + dynamic::object("type", "linear-gradient")( + "direction", dynamic::object("type", "angle")("value", 180))( + "colorStops", + dynamic::array( + dynamic::object("color", kRed)("position", nullptr), + dynamic::object("color", nullptr)("position", "20%"), + dynamic::object("color", static_cast(0xFF0000FF))( + "position", nullptr)))))}}); +} + +} // namespace facebook::react + +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/BoxShadow.h b/packages/react-native/ReactCommon/react/renderer/graphics/BoxShadow.h index b8664bc44a37..2a3284f9c7e5 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/BoxShadow.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/BoxShadow.h @@ -32,7 +32,9 @@ struct BoxShadow { result["offsetY"] = offsetY; result["blurRadius"] = blurRadius; result["spreadDistance"] = spreadDistance; - result["color"] = *color; + if (color) { + result["color"] = *color; + } result["inset"] = inset; return result; } diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/ColorStop.cpp b/packages/react-native/ReactCommon/react/renderer/graphics/ColorStop.cpp index 01cf692d99dd..a57690fcaf35 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/ColorStop.cpp +++ b/packages/react-native/ReactCommon/react/renderer/graphics/ColorStop.cpp @@ -12,7 +12,7 @@ namespace facebook::react { #ifdef RN_SERIALIZABLE_STATE folly::dynamic ColorStop::toDynamic() const { folly::dynamic result = folly::dynamic::object(); - result["color"] = *color; + result["color"] = color ? folly::dynamic(*color) : folly::dynamic(nullptr); result["position"] = position.toDynamic(); return result; } diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/Filter.h b/packages/react-native/ReactCommon/react/renderer/graphics/Filter.h index fc18207f93a2..bfe3b9c6a848 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/Filter.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/Filter.h @@ -102,7 +102,9 @@ struct DropShadowParams { result["offsetX"] = offsetX; result["offsetY"] = offsetY; result["standardDeviation"] = standardDeviation; - result["color"] = *color; + if (color) { + result["color"] = *color; + } return result; } #endif diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/TransformUtils.h b/packages/react-native/ReactCommon/react/renderer/graphics/TransformUtils.h index daefaebfeb3e..bcc366cbf69b 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/TransformUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/TransformUtils.h @@ -75,7 +75,7 @@ inline void updateTransformProps( serializeTransformAxis(operation, "rotate", 0, resultTranslateArray); return; case TransformOperationType::Perspective: - serializeTransformAxis(operation, "perspective", 0, resultTranslateArray); + serializeTransformOperationValue("perspective", operation.x, resultTranslateArray); return; case TransformOperationType::Arbitrary: { folly::dynamic result = folly::dynamic::object();