Skip to content

Commit 281fc5a

Browse files
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: #59078 Test Plan: - New `ViewPropsDiffTest`, using the payload helper from #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** <table> <tr><th>Before</th><th>After</th></tr> <tr> <td> https://github.com/user-attachments/assets/6e337636-608d-4c06-996f-914ff005528d </td> <td> https://github.com/user-attachments/assets/6d346094-eff6-4b20-a1aa-693988c47048 </td> </tr> </table> **Flip a card with `perspective: 400`** <table> <tr><th>Before</th><th>After</th></tr> <tr> <td> https://github.com/user-attachments/assets/9fcdcbc0-ec94-43be-aa95-31e8d5b813c3 </td> <td> https://github.com/user-attachments/assets/7942a58c-f7c1-4292-aa02-edadb31924d3 </td> </tr> </table> **Remove `borderLeftWidth` (`borderWidth: 8`)** <table> <tr><th>Before</th><th>After</th></tr> <tr> <td> https://github.com/user-attachments/assets/5098d21a-2686-4d01-9120-07247832579e </td> <td> https://github.com/user-attachments/assets/758dc39e-230e-45ec-81d5-098f601183d6 </td> </tr> </table> **`boxShadow` and `drop-shadow` without a color** <table> <tr><th>Before</th><th>After</th></tr> <tr> <td> https://github.com/user-attachments/assets/20fa2a78-240f-4e62-afa9-b0f687b1493c </td> <td> https://github.com/user-attachments/assets/2c6257f5-e978-4710-b0b9-b1fdb34f1052 </td> </tr> </table> **Remove `shadowColor` (`elevation: 24`)** <table> <tr><th>Before</th><th>After</th></tr> <tr> <td> https://github.com/user-attachments/assets/e74d27b5-6c1c-45f2-be88-a27b6a5fb0cd </td> <td> https://github.com/user-attachments/assets/55593913-96ea-46e5-91b8-c1fd3cf53c43 </td> </tr> </table> **Add a 20% color hint** <table> <tr><th>Before</th><th>After</th></tr> <tr> <td> https://github.com/user-attachments/assets/9f2c2b2a-8b39-4e0d-9ca6-5c972c5abf75 </td> <td> https://github.com/user-attachments/assets/b4ab4fa6-7d92-4705-af76-bae13b091bc4 </td> </tr> </table> <details><summary><code>RNTesterPlayground.js</code>: paste into <code>packages/rn-tester/js/examples/Playground/RNTesterPlayground.js</code> and open Playground.</summary> ```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 ( <View> <Button testID="toggle" title={on ? 'Flip back' : 'Flip card'} onPress={() => setOn(v => !v)} /> <Text testID="state"> transform: perspective 400, rotateY {on ? '60deg' : '0deg'} </Text> <View style={styles.row}> <View style={[ styles.card, {transform: [{perspective: 400}, {rotateY: on ? '60deg' : '0deg'}]}, ]}> <Text style={styles.cardText}>CARD</Text> </View> </View> </View> ); } function Border() { const [on, setOn] = useState(true); return ( <View> <Button testID="toggle" title={on ? 'Remove borderLeftWidth' : 'Set borderLeftWidth'} onPress={() => setOn(v => !v)} /> <Text testID="state"> borderWidth 8{on ? ', borderLeftWidth 32' : ''} </Text> <View style={styles.row}> <View style={[styles.bordered, on && {borderLeftWidth: 32}]} /> </View> </View> ); } function Shadows() { const [on, setOn] = useState(true); return ( <View> <Button testID="toggle" title={on ? 'Remove shadows' : 'Set shadows'} onPress={() => setOn(v => !v)} /> <Text testID="state"> {on ? 'boxShadow, drop-shadow: no color' : 'no shadows'} </Text> <View style={styles.row}> <View style={[styles.box, on && {boxShadow: '0 12px 16px'}]}> <Text>boxShadow</Text> </View> <View style={[styles.box, on && {filter: 'drop-shadow(0 12px 6px)'}]}> <Text>drop-shadow</Text> </View> </View> </View> ); } function ShadowColor() { const [on, setOn] = useState(true); return ( <View> <Button testID="toggle" title={on ? 'Remove shadowColor' : 'Set shadowColor'} onPress={() => setOn(v => !v)} /> <Text testID="state"> elevation 24, shadowColor {on ? 'red' : 'unset'} </Text> <View style={styles.row}> <View style={[styles.box, styles.elevated, on && {shadowColor: 'red'}]}> <Text>elevation</Text> </View> </View> </View> ); } function Gradient() { const [hint, setHint] = useState(false); return ( <View> <Button testID="toggle" title={hint ? 'Remove the 20% hint' : 'Add a 20% hint'} onPress={() => setHint(v => !v)} /> <Text testID="state"> linear-gradient(red, {hint ? '20%, ' : ''}blue) </Text> <View style={styles.row}> <View style={[ styles.gradient, { backgroundImage: hint ? 'linear-gradient(red, 20%, blue)' : 'linear-gradient(red, blue)', }, ]} /> </View> </View> ); } const demos = { perspective: Perspective, border: Border, shadows: Shadows, shadowColor: ShadowColor, gradient: Gradient, }; function Playground() { const [demo, setDemo] = useState<$Keys<typeof demos>>('perspective'); const Demo = demos[demo]; return ( <View style={styles.container}> <View style={styles.tabs}> {Object.keys(demos).map(name => ( <Button key={name} testID={'tab-' + name} title={name} onPress={() => setDemo(name)} /> ))} </View> <Demo key={demo} /> </View> ); } const styles = StyleSheet.create({ bordered: { backgroundColor: '#ffd54f', borderColor: '#1a237e', borderWidth: 8, height: 120, width: 200, }, box: { alignItems: 'center', backgroundColor: 'white', borderRadius: 12, height: 120, justifyContent: 'center', margin: 16, width: 140, }, card: { alignItems: 'center', backgroundColor: '#1e88e5', height: 140, justifyContent: 'center', width: 200, }, cardText: {color: 'white', fontSize: 28, fontWeight: 'bold'}, container: {padding: 10}, elevated: {elevation: 24}, gradient: {height: 160, width: 240}, row: { alignItems: 'center', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', marginTop: 24, }, tabs: {flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginBottom: 12}, }); export default { title: 'Playground', name: 'playground', description: 'Test out new features and ideas.', render: (): React.Node => <Playground />, } as RNTesterModuleExample; ``` </details> Differential Revision: D125438461 Pulled By: bartlomiejbloniarz
1 parent 88974ca commit 281fc5a

6 files changed

Lines changed: 181 additions & 6 deletions

File tree

‎packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ static void updateBorderWidthPropValue(
159159
if (newValue.has_value()) {
160160
result[propName] = newValue.value();
161161
} else {
162-
result[propName] = NULL;
162+
result[propName] = nullptr;
163163
}
164164
}
165165
}
@@ -449,6 +449,56 @@ inline static void updateAccessibilityStateProp(
449449
result["accessibilityState"] = resultState;
450450
}
451451

452+
static folly::dynamic toDynamic(
453+
const std::variant<std::monostate, ValueUnit>& lengthPercentageOrAuto) {
454+
const auto* valueUnit = std::get_if<ValueUnit>(&lengthPercentageOrAuto);
455+
return valueUnit != nullptr ? valueUnit->toDynamic() : "auto";
456+
}
457+
458+
static folly::dynamic toDynamic(const BackgroundSize& backgroundSize) {
459+
if (const auto* keyword =
460+
std::get_if<BackgroundSizeKeyword>(&backgroundSize)) {
461+
return *keyword == BackgroundSizeKeyword::Cover ? "cover" : "contain";
462+
}
463+
const auto& size = std::get<BackgroundSizeLengthPercentage>(backgroundSize);
464+
return folly::dynamic::object("x", toDynamic(size.x))("y", toDynamic(size.y));
465+
}
466+
467+
// JS sends one edge per axis, but the parser keeps the default top and left
468+
// next to a bottom or right.
469+
static folly::dynamic toDynamic(const BackgroundPosition& backgroundPosition) {
470+
folly::dynamic result = folly::dynamic::object();
471+
if (backgroundPosition.bottom.has_value()) {
472+
result["bottom"] = backgroundPosition.bottom->toDynamic();
473+
} else if (backgroundPosition.top.has_value()) {
474+
result["top"] = backgroundPosition.top->toDynamic();
475+
}
476+
if (backgroundPosition.right.has_value()) {
477+
result["right"] = backgroundPosition.right->toDynamic();
478+
} else if (backgroundPosition.left.has_value()) {
479+
result["left"] = backgroundPosition.left->toDynamic();
480+
}
481+
return result;
482+
}
483+
484+
static const char* toString(BackgroundRepeatStyle backgroundRepeatStyle) {
485+
switch (backgroundRepeatStyle) {
486+
case BackgroundRepeatStyle::Repeat:
487+
return "repeat";
488+
case BackgroundRepeatStyle::Space:
489+
return "space";
490+
case BackgroundRepeatStyle::Round:
491+
return "round";
492+
case BackgroundRepeatStyle::NoRepeat:
493+
return "no-repeat";
494+
}
495+
}
496+
497+
static folly::dynamic toDynamic(const BackgroundRepeat& backgroundRepeat) {
498+
return folly::dynamic::object("x", toString(backgroundRepeat.x))(
499+
"y", toString(backgroundRepeat.y));
500+
}
501+
452502
ComponentName HostPlatformViewProps::getDiffPropsImplementationTarget() const {
453503
return "View";
454504
}
@@ -528,7 +578,8 @@ folly::dynamic HostPlatformViewProps::getDiffProps(
528578
}
529579

530580
if (shadowColor != oldProps->shadowColor) {
531-
result["shadowColor"] = *shadowColor;
581+
result["shadowColor"] =
582+
shadowColor ? folly::dynamic(*shadowColor) : folly::dynamic(nullptr);
532583
}
533584

534585
if (shadowOpacity != oldProps->shadowOpacity) {
@@ -580,6 +631,18 @@ folly::dynamic HostPlatformViewProps::getDiffProps(
580631
result["backgroundImage"] = toDynamic(backgroundImage);
581632
}
582633

634+
if (backgroundSize != oldProps->backgroundSize) {
635+
result["backgroundSize"] = toDynamic(backgroundSize);
636+
}
637+
638+
if (backgroundPosition != oldProps->backgroundPosition) {
639+
result["backgroundPosition"] = toDynamic(backgroundPosition);
640+
}
641+
642+
if (backgroundRepeat != oldProps->backgroundRepeat) {
643+
result["backgroundRepeat"] = toDynamic(backgroundRepeat);
644+
}
645+
583646
if (mixBlendMode != oldProps->mixBlendMode) {
584647
result["mixBlendMode"] = toString(mixBlendMode);
585648
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#include <gtest/gtest.h>
9+
10+
#include <react/renderer/components/view/ViewProps.h>
11+
#include <react/test_utils/PropsPayloads.h>
12+
13+
#ifdef RN_SERIALIZABLE_STATE
14+
15+
namespace facebook::react {
16+
17+
namespace {
18+
19+
using folly::dynamic;
20+
21+
void expectPayloads(const std::vector<PropsCommit>& commits) {
22+
expectPayloadsMatchRawProps<ViewProps>(commits);
23+
}
24+
25+
const auto kRed = static_cast<int32_t>(0xFFFF0000);
26+
27+
} // namespace
28+
29+
// ReactViewManager has setters for all three; with
30+
// enableExclusivePropsUpdateAndroid there is no rawProps fallback.
31+
TEST(ViewPropsDiffTest, emitsBackgroundSizePositionAndRepeat) {
32+
expectPayloads({{dynamic::object(
33+
"backgroundSize",
34+
dynamic::array(
35+
dynamic::object("x", "50%")("y", "auto"),
36+
dynamic::object("x", 20)("y", "25%")))(
37+
"backgroundPosition",
38+
dynamic::array(
39+
dynamic::object("top", "10%")("left", 5),
40+
dynamic::object("bottom", 0)("right", "20%")))(
41+
"backgroundRepeat",
42+
dynamic::array(dynamic::object("x", "no-repeat")("y", "space")))}});
43+
}
44+
45+
// The Android transform parser knows `perspective`, not `perspectiveX`.
46+
TEST(ViewPropsDiffTest, emitsPerspectiveUnderItsOwnName) {
47+
expectPayloads({{dynamic::object(
48+
"transform", dynamic::array(dynamic::object("perspective", 850)))}});
49+
}
50+
51+
// ReactViewManager resets a side to `borderWidth` only for null (NaN); 0 keeps
52+
// the side at width 0.
53+
TEST(ViewPropsDiffTest, removingASideBorderWidthEmitsNull) {
54+
expectPayloads({
55+
{dynamic::object("borderWidth", 4)("borderLeftWidth", 12)},
56+
{dynamic::object("borderLeftWidth", nullptr)},
57+
});
58+
}
59+
60+
// BoxShadow.parse defaults an absent color to black; 0 is transparent.
61+
TEST(ViewPropsDiffTest, boxShadowWithoutColorOmitsColor) {
62+
expectPayloads({{dynamic::object(
63+
"boxShadow",
64+
dynamic::array(
65+
dynamic::object("offsetX", 0)("offsetY", 4)("blurRadius", 8)(
66+
"spreadDistance", 0)("inset", false)))}});
67+
}
68+
69+
// FilterHelper defaults an absent drop shadow color to black; 0 is
70+
// transparent.
71+
TEST(ViewPropsDiffTest, dropShadowWithoutColorOmitsColor) {
72+
expectPayloads({{dynamic::object(
73+
"filter",
74+
dynamic::array(
75+
dynamic::object(
76+
"dropShadow",
77+
dynamic::object("offsetX", 0)("offsetY", 4)(
78+
"standardDeviation", 4))))}});
79+
}
80+
81+
// BaseViewManager.setShadowColor resets to black for null; 0 makes the
82+
// elevation shadow transparent.
83+
TEST(ViewPropsDiffTest, removingShadowColorEmitsNull) {
84+
expectPayloads({
85+
{dynamic::object("elevation", 8)("shadowColor", kRed)},
86+
{dynamic::object("shadowColor", nullptr)},
87+
});
88+
}
89+
90+
// The Android gradients treat a stop with a null color as a transition hint;
91+
// 0 is a transparent stop.
92+
TEST(ViewPropsDiffTest, gradientColorHintEmitsNullColor) {
93+
expectPayloads({{dynamic::object(
94+
"backgroundImage",
95+
dynamic::array(
96+
dynamic::object("type", "linear-gradient")(
97+
"direction", dynamic::object("type", "angle")("value", 180))(
98+
"colorStops",
99+
dynamic::array(
100+
dynamic::object("color", kRed)("position", nullptr),
101+
dynamic::object("color", nullptr)("position", "20%"),
102+
dynamic::object("color", static_cast<int32_t>(0xFF0000FF))(
103+
"position", nullptr)))))}});
104+
}
105+
106+
} // namespace facebook::react
107+
108+
#endif

‎packages/react-native/ReactCommon/react/renderer/graphics/BoxShadow.h‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ struct BoxShadow {
3232
result["offsetY"] = offsetY;
3333
result["blurRadius"] = blurRadius;
3434
result["spreadDistance"] = spreadDistance;
35-
result["color"] = *color;
35+
if (color) {
36+
result["color"] = *color;
37+
}
3638
result["inset"] = inset;
3739
return result;
3840
}

‎packages/react-native/ReactCommon/react/renderer/graphics/ColorStop.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace facebook::react {
1212
#ifdef RN_SERIALIZABLE_STATE
1313
folly::dynamic ColorStop::toDynamic() const {
1414
folly::dynamic result = folly::dynamic::object();
15-
result["color"] = *color;
15+
result["color"] = color ? folly::dynamic(*color) : folly::dynamic(nullptr);
1616
result["position"] = position.toDynamic();
1717
return result;
1818
}

‎packages/react-native/ReactCommon/react/renderer/graphics/Filter.h‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@ struct DropShadowParams {
102102
result["offsetX"] = offsetX;
103103
result["offsetY"] = offsetY;
104104
result["standardDeviation"] = standardDeviation;
105-
result["color"] = *color;
105+
if (color) {
106+
result["color"] = *color;
107+
}
106108
return result;
107109
}
108110
#endif

‎packages/react-native/ReactCommon/react/renderer/graphics/TransformUtils.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ inline void updateTransformProps(
7575
serializeTransformAxis(operation, "rotate", 0, resultTranslateArray);
7676
return;
7777
case TransformOperationType::Perspective:
78-
serializeTransformAxis(operation, "perspective", 0, resultTranslateArray);
78+
serializeTransformOperationValue("perspective", operation.x, resultTranslateArray);
7979
return;
8080
case TransformOperationType::Arbitrary: {
8181
folly::dynamic result = folly::dynamic::object();

0 commit comments

Comments
 (0)