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/3] 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 281fc5ac368583f5b8752f30bf81ed0fd3020096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Fri, 16 Oct 2026 02:15:43 -0700 Subject: [PATCH 2/3] 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(); From 3c286cf2fc373c6f4304f357b3c0729130c5468a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Fri, 16 Oct 2026 02:44:14 -0700 Subject: [PATCH 3/3] Fix View accessibility payloads that Props 2.0 sends wrong or drops (#59079) Summary: With Props 2.0 on Android (`enableExclusivePropsUpdateAndroid` and the related flags), `BaseViewManager` receives the View payload from `getDiffProps`. Some accessibility props come out wrong: - Changing one member of `accessibilityState`, such as `busy`: only that member is sent, and Java treats the map as the whole state, so a selected, disabled view becomes unselected and enabled. - A removed `accessibilityRole` or `role`: not cleared. - An explicit `role="none"`: not sent on mount, so the view falls back to `accessibilityRole`. - A removed `accessibilityValue`: sent as `{}`, so the old value stays in the content description. This PR sends `accessibilityState` in full, clears a removed role or value, and makes `role` optional so that unset and `"none"` differ. Nothing changes on Android with the flags off. iOS shares the props constructor, so a removed role is now also cleared in its C++ props. I didn't test iOS. ## Changelog: [ANDROID] [FIXED] - Fix accessibilityState, a removed accessibilityRole, role or accessibilityValue, and role="none" with Props 2.0 Pull Request resolved: https://github.com/react/react-native/pull/59079 Test Plan: - New `ViewPropsDiffTest` cases, 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 Playground screen below, checked with `uiautomator dump`. Setting `busy` keeps the view selected and disabled. Removing the props: | | Flags off | Main, Props 2.0 | This PR, Props 2.0 | |---|---|---|---| | `role` removed | `android.view.ViewGroup` | `android.widget.Button` | `android.view.ViewGroup` | | `accessibilityRole` removed | `android.view.ViewGroup` | `android.widget.Button` | `android.view.ViewGroup` | | `accessibilityValue` removed | content description cleared | `"value target, 50%"` | content description cleared | | `role="none"` + `accessibilityRole="button"` | `android.view.View` | `android.widget.Button` | `android.view.View` |
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 A11yState() { const [busy, setBusy] = useState(false); return (
Differential Revision: D125438453 Pulled By: bartlomiejbloniarz --- .../View/RCTViewComponentView.mm | 9 +- .../components/view/AccessibilityProps.cpp | 15 ++-- .../components/view/AccessibilityProps.h | 2 +- .../components/view/HostPlatformViewProps.cpp | 87 ++++++++----------- .../view/tests/ViewPropsDiffTest.cpp | 49 +++++++++++ .../api-snapshots/ReactAndroidDebugCxx.api | 2 +- .../ReactAndroidFrameworksCxx.api | 2 +- .../api-snapshots/ReactAndroidNewarchCxx.api | 2 +- .../api-snapshots/ReactAndroidReleaseCxx.api | 2 +- .../api-snapshots/ReactAppleDebugCxx.api | 2 +- .../api-snapshots/ReactAppleFrameworksCxx.api | 2 +- .../api-snapshots/ReactAppleNewarchCxx.api | 2 +- .../api-snapshots/ReactAppleReleaseCxx.api | 2 +- .../api-snapshots/ReactCommonDebugCxx.api | 2 +- .../ReactCommonFrameworksCxx.api | 2 +- .../api-snapshots/ReactCommonNewarchCxx.api | 2 +- .../api-snapshots/ReactCommonReleaseCxx.api | 2 +- 17 files changed, 111 insertions(+), 75 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 582557336d61..71b38291fdf5 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -67,7 +67,7 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro // `role` wins over the legacy `accessibilityRole` when both are set, matching // how the traits themselves are resolved. - if (props.role != Role::None) { + if (props.role.value_or(Role::None) != Role::None) { static const std::unordered_set interactiveRoles{ Role::Button, Role::Checkbox, @@ -82,7 +82,7 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro Role::Switch, Role::Tab, Role::Treeitem}; - return interactiveRoles.contains(props.role); + return interactiveRoles.contains(*props.role); } static const std::unordered_set interactiveAccessibilityRoles{ @@ -1678,8 +1678,9 @@ - (NSString *)accessibilityValue } NSMutableArray *valueComponents = [NSMutableArray new]; - NSString *roleString = (props.role != Role::None) ? [NSString stringWithUTF8String:toString(props.role).c_str()] - : [NSString stringWithUTF8String:props.accessibilityRole.c_str()]; + NSString *roleString = (props.role.value_or(Role::None) != Role::None) + ? [NSString stringWithUTF8String:toString(*props.role).c_str()] + : [NSString stringWithUTF8String:props.accessibilityRole.c_str()]; // In iOS, checkbox and radio buttons aren't recognized as traits. However, // because our apps use checkbox and radio buttons often, we should announce diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp index b204a3fd3c58..4bc58608d2a2 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp @@ -18,12 +18,12 @@ namespace facebook::react { // members. This makes accessibilityTraits the single source of truth. // See: github.com/facebook/react-native/issues/57515 static AccessibilityTraits deriveAccessibilityTraits( - Role role, + std::optional role, const std::string& accessibilityRole, const std::optional& accessibilityState) { AccessibilityTraits traits = AccessibilityTraits::None; - if (role != Role::None) { - fromString(toString(role), traits); + if (role.value_or(Role::None) != Role::None) { + fromString(toString(*role), traits); } else if (!accessibilityRole.empty()) { fromString(accessibilityRole, traits); } @@ -179,16 +179,15 @@ AccessibilityProps::AccessibilityProps( auto* accessibilityRoleValue = rawProps.at("accessibilityRole"); auto* roleValue = rawProps.at("role"); - if (accessibilityRoleValue == nullptr || - !accessibilityRoleValue->hasValue()) { + if (accessibilityRoleValue == nullptr) { accessibilityRole = sourceProps.accessibilityRole; - } else { + } else if (accessibilityRoleValue->hasValue()) { fromRawValue(context, *accessibilityRoleValue, accessibilityRole); } - if (roleValue == nullptr || !roleValue->hasValue()) { + if (roleValue == nullptr) { role = sourceProps.role; - } else { + } else if (roleValue->hasValue()) { fromRawValue(context, *roleValue, role); } diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.h index 53524bb4a015..0b055d60f9b8 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.h @@ -56,7 +56,7 @@ class AccessibilityProps { bool onAccessibilityEscape{}; bool onAccessibilityAction{}; ImportantForAccessibility importantForAccessibility{ImportantForAccessibility::Auto}; - Role role{Role::None}; + std::optional role{}; std::string testId; #pragma mark - DebugStringConvertible 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 d2417698a5f6..2f2820422161 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 @@ -395,58 +395,39 @@ inline static void updateNativeDrawableProp( result[propName] = nativeDrawableResult; } -inline static void updateAccessibilityStateProp( - folly::dynamic& result, - const std::optional& newState, - const std::optional& oldState) { - folly::dynamic resultState = folly::dynamic::object(); - - if (!newState.has_value() && oldState.has_value()) { - result["accessibilityState"] = resultState; - return; - } +static folly::dynamic toDynamic(const AccessibilityState& state) { + folly::dynamic result = folly::dynamic::object(); + result["disabled"] = state.disabled; - if (!oldState.has_value() || newState->disabled != oldState->disabled) { - resultState["disabled"] = newState->disabled; + // Omitting the key when `selected` is unset is what tells the platform the + // component is not selectable. BaseViewManager#setViewState falls back to + // `setSelected(false)` for an absent key, so the rendered result is + // unchanged from when this was a plain `bool`. + if (state.selected.has_value()) { + result["selected"] = state.selected.value(); } - if (!oldState.has_value() || newState->selected != oldState->selected) { - // Omitting the key when `selected` is unset is what tells the platform the - // component is not selectable. BaseViewManager#setViewState falls back to - // `setSelected(false)` for an absent key, so the rendered result is - // unchanged from when this was a plain `bool`. - if (newState->selected.has_value()) { - resultState["selected"] = newState->selected.value(); - } - } + result["busy"] = state.busy; - if (!oldState.has_value() || newState->busy != oldState->busy) { - resultState["busy"] = newState->busy; + if (state.expanded.has_value()) { + result["expanded"] = state.expanded.value(); } - if (!oldState.has_value() || newState->expanded != oldState->expanded) { - if (newState->expanded.has_value()) { - resultState["expanded"] = newState->expanded.value(); - } + switch (state.checked) { + case AccessibilityState::Unchecked: + result["checked"] = false; + break; + case AccessibilityState::Checked: + result["checked"] = true; + break; + case AccessibilityState::Mixed: + result["checked"] = "mixed"; + break; + case AccessibilityState::None: + result["checked"] = "none"; + break; } - - if (!oldState.has_value() || newState->checked != oldState->checked) { - switch (newState->checked) { - case AccessibilityState::Unchecked: - resultState["checked"] = false; - break; - case AccessibilityState::Checked: - resultState["checked"] = true; - break; - case AccessibilityState::Mixed: - resultState["checked"] = "mixed"; - break; - case AccessibilityState::None: - resultState["checked"] = "none"; - break; - } - } - result["accessibilityState"] = resultState; + return result; } static folly::dynamic toDynamic( @@ -540,7 +521,8 @@ folly::dynamic HostPlatformViewProps::getDiffProps( } if (role != oldProps->role) { - result["role"] = toString(role); + result["role"] = + role.has_value() ? toString(*role) : folly::dynamic(nullptr); } if (opacity != oldProps->opacity) { @@ -907,8 +889,9 @@ folly::dynamic HostPlatformViewProps::getDiffProps( // Accessibility if (accessibilityState != oldProps->accessibilityState) { - updateAccessibilityStateProp( - result, accessibilityState, oldProps->accessibilityState); + result["accessibilityState"] = accessibilityState.has_value() + ? toDynamic(*accessibilityState) + : folly::dynamic::object(); } if (accessibilityLabel != oldProps->accessibilityLabel) { @@ -941,7 +924,9 @@ folly::dynamic HostPlatformViewProps::getDiffProps( } if (accessibilityRole != oldProps->accessibilityRole) { - result["accessibilityRole"] = accessibilityRole; + result["accessibilityRole"] = accessibilityRole.empty() + ? folly::dynamic(nullptr) + : folly::dynamic(accessibilityRole); } if (accessibilityLanguage != oldProps->accessibilityLanguage) { @@ -962,7 +947,9 @@ folly::dynamic HostPlatformViewProps::getDiffProps( if (accessibilityValue.text.has_value()) { accessibilityValueObject["text"] = accessibilityValue.text.value(); } - result["accessibilityValue"] = accessibilityValueObject; + result["accessibilityValue"] = accessibilityValueObject.empty() + ? folly::dynamic(nullptr) + : accessibilityValueObject; } if (accessibilityActions != oldProps->accessibilityActions) { 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 index 0c328359c855..7fe5a6dfdab3 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/tests/ViewPropsDiffTest.cpp @@ -103,6 +103,55 @@ TEST(ViewPropsDiffTest, gradientColorHintEmitsNullColor) { "position", nullptr)))))}}); } +// BaseViewManager.setViewState replaces the whole state: an absent `selected` +// deselects the view and an absent `disabled` re-enables it. Props 2.0 sends an +// unset `checked` as "none", which Java ignores. +TEST(ViewPropsDiffTest, emitsTheWholeAccessibilityStateWhenOneMemberChanges) { + auto state = [](bool busy) { + return dynamic::object("selected", true)("disabled", true)("busy", busy); + }; + auto sentState = [&](bool busy) { + return dynamic::object( + "accessibilityState", state(busy)("checked", "none")); + }; + expectPayloads({ + {dynamic::object("accessibilityState", state(false)), sentState(false)}, + {dynamic::object("accessibilityState", state(true)), sentState(true)}, + }); +} + +// React sends a removed prop as null on top of the previous props. +// BaseViewManager clears the role only for null, and +// AccessibilityRole.fromValue("") throws. +TEST(ViewPropsDiffTest, removingAccessibilityRoleEmitsNull) { + expectPayloads({ + {dynamic::object("accessibilityRole", "button")}, + {dynamic::object("accessibilityRole", nullptr)}, + }); +} + +// BaseViewManager falls back to accessibilityRole only for a null role. +TEST(ViewPropsDiffTest, removingRoleEmitsNull) { + expectPayloads({ + {dynamic::object("role", "button")}, + {dynamic::object("role", nullptr)}, + }); +} + +// An explicit role="none" overrides accessibilityRole. +TEST(ViewPropsDiffTest, emitsAnExplicitRoleNone) { + expectPayloads( + {{dynamic::object("role", "none")("accessibilityRole", "button")}}); +} + +// BaseViewManager clears the value and the content description only for null. +TEST(ViewPropsDiffTest, removingAccessibilityValueEmitsNull) { + expectPayloads({ + {dynamic::object("accessibilityValue", dynamic::object("text", "50%"))}, + {dynamic::object("accessibilityValue", nullptr)}, + }); +} + } // namespace facebook::react #endif diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 0a971585b18c..3dcbe62a3682 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -700,8 +700,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index f0ce34575668..41bfbac0b535 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -1010,8 +1010,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index a9d04bddb9d5..f92cdcbd7436 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -700,8 +700,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index a9d04bddb9d5..f92cdcbd7436 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -700,8 +700,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index bdd5a4e46f56..6192b4485801 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -3430,8 +3430,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index f1f68b19e566..7985324c6989 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -3785,8 +3785,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 02c316b08ddc..4be08efb71ed 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -3422,8 +3422,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 8b82a486f934..cb0cf4a1edb0 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -3430,8 +3430,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index 5e394479d606..3bfeb8a6447c 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -321,8 +321,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api index 085f78edc124..636df64042f7 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api @@ -566,8 +566,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index 6ccc50a5a89a..c614fa5bbeec 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -321,8 +321,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index 6ccc50a5a89a..c614fa5bbeec 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -321,8 +321,8 @@ class facebook::react::AccessibilityProps { public facebook::react::AccessibilityTraits accessibilityTraits; public facebook::react::AccessibilityValue accessibilityValue; public facebook::react::ImportantForAccessibility importantForAccessibility; - public facebook::react::Role role; public std::optional accessibilityState; + public std::optional role; public std::string accessibilityHint; public std::string accessibilityLabel; public std::string accessibilityLanguage;