Skip to content

Commit 089c9a5

Browse files
NickGerlemanfacebook-github-bot
authored andcommitted
Fix AttributedString comparison logic for TextInput state updates
Summary: D37801394 (facebook@51f49ca) attempted to fix an issue of TextInput values being dropped when an uncontrolled component is restyled, and a defaultValue is present. I had missed quite a bit of functionality, where TextInput may have child Text elements, which the native side flattens into a single AttributedString. `lastNativeValue` includes a lossy version of the flattened string produced from the child fragments, so sending it along with the children led to duplicating of the current input on each edit, and things blow up. With some experimentation, I found that the text-loss behavior only happens on Fabric, and is triggered by a state update rather than my original assumption of the view manager command in the `useLayoutEffect` hook. `AndroidTextInputShadowNode` will compare the current and previous flattened strings, to intentionally allow the native value to drift from the React tree if the React tree hasn't changed. This `AttributedString` comparison includes layout metrics as of D20151505 (facebook@061f54e) meaning a restyle may cause a state update, and clear the text. I do not have full understanding of the flow of state updates to layout, or the underlying issue that led to the equality check including layout information (since TextMeasurementCache seems to explicitly compare LayoutMetrics). D18894538 (facebook@254ebab) used a solution of sending a no-op state update to avoid updating text for placeholders, when the Attributed strings are equal (though as of now this code is never reached, since we return earlier on AttributedString equality). I co-opted this mechanism, to avoid sending text updates if the text content and attributes of the AttributedString has not changed, disregarding any layout information. This is how the comparison worked at the time of the diff. I also updated the fragment hashing function to include layout metrics, since it was added to be part of the equality check, and is itself hashable. Changelog: [Android][Fixed] - Fix `AttributedString` comparison logic for TextInput state updates Reviewed By: sammy-SC Differential Revision: D37902643 fbshipit-source-id: c0f8e3112feb19bd0ee62b37bdadeb237a9f725e
1 parent b708ee9 commit 089c9a5

File tree

4 files changed

+67
-7
lines changed

4 files changed

+67
-7
lines changed

ReactCommon/react/renderer/attributedstring/AttributedString.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ bool Fragment::operator==(const Fragment &rhs) const {
3838
rhs.parentShadowView.layoutMetrics);
3939
}
4040

41+
bool Fragment::isContentEqual(const Fragment &rhs) const {
42+
return std::tie(string, textAttributes) ==
43+
std::tie(rhs.string, rhs.textAttributes);
44+
}
45+
4146
bool Fragment::operator!=(const Fragment &rhs) const {
4247
return !(*this == rhs);
4348
}
@@ -126,6 +131,20 @@ bool AttributedString::operator!=(const AttributedString &rhs) const {
126131
return !(*this == rhs);
127132
}
128133

134+
bool AttributedString::isContentEqual(const AttributedString &rhs) const {
135+
if (fragments_.size() != rhs.fragments_.size()) {
136+
return false;
137+
}
138+
139+
for (auto i = 0; i < fragments_.size(); i++) {
140+
if (!fragments_[i].isContentEqual(rhs.fragments_[i])) {
141+
return false;
142+
}
143+
}
144+
145+
return true;
146+
}
147+
129148
#pragma mark - DebugStringConvertible
130149

131150
#if RN_DEBUG_STRING_CONVERTIBLE

ReactCommon/react/renderer/attributedstring/AttributedString.h

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ class AttributedString : public Sealable, public DebugStringConvertible {
4646
*/
4747
bool isAttachment() const;
4848

49+
/*
50+
* Returns whether the underlying text and attributes are equal,
51+
* disregarding layout or other information.
52+
*/
53+
bool isContentEqual(const Fragment &rhs) const;
54+
4955
bool operator==(const Fragment &rhs) const;
5056
bool operator!=(const Fragment &rhs) const;
5157
};
@@ -96,6 +102,8 @@ class AttributedString : public Sealable, public DebugStringConvertible {
96102
*/
97103
bool compareTextAttributesWithoutFrame(const AttributedString &rhs) const;
98104

105+
bool isContentEqual(const AttributedString &rhs) const;
106+
99107
bool operator==(const AttributedString &rhs) const;
100108
bool operator!=(const AttributedString &rhs) const;
101109

@@ -118,7 +126,11 @@ struct hash<facebook::react::AttributedString::Fragment> {
118126
size_t operator()(
119127
const facebook::react::AttributedString::Fragment &fragment) const {
120128
return folly::hash::hash_combine(
121-
0, fragment.string, fragment.textAttributes, fragment.parentShadowView);
129+
0,
130+
fragment.string,
131+
fragment.textAttributes,
132+
fragment.parentShadowView,
133+
fragment.parentShadowView.layoutMetrics);
122134
}
123135
};
124136

ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,17 @@ void AndroidTextInputShadowNode::updateStateIfNeeded() {
141141
auto defaultTextAttributes = TextAttributes::defaultTextAttributes();
142142
defaultTextAttributes.apply(getConcreteProps().textAttributes);
143143

144-
auto newEventCount =
145-
(state.reactTreeAttributedString == reactTreeAttributedString
146-
? 0
147-
: getConcreteProps().mostRecentEventCount);
148-
auto newAttributedString = getMostRecentAttributedString();
149-
150144
// Even if we're here and updating state, it may be only to update the layout
151145
// manager If that is the case, make sure we don't update text: pass in the
152146
// current attributedString unchanged, and pass in zero for the "event count"
153147
// so no changes are applied There's no way to prevent a state update from
154148
// flowing to Java, so we just ensure it's a noop in those cases.
149+
auto newEventCount =
150+
state.reactTreeAttributedString.isContentEqual(reactTreeAttributedString)
151+
? 0
152+
: getConcreteProps().mostRecentEventCount;
153+
auto newAttributedString = getMostRecentAttributedString();
154+
155155
setStateData(AndroidTextInputState{
156156
newEventCount,
157157
newAttributedString,

packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ const styles = StyleSheet.create({
7272
margin: 3,
7373
fontSize: 12,
7474
},
75+
focusedUncontrolled: {
76+
margin: -2,
77+
borderWidth: 2,
78+
borderColor: '#0a0a0a',
79+
flex: 1,
80+
fontSize: 13,
81+
padding: 4,
82+
},
7583
});
7684

7785
class WithLabel extends React.Component<$FlowFixMeProps> {
@@ -477,6 +485,20 @@ class SelectionExample extends React.Component<
477485
}
478486
}
479487

488+
function UncontrolledExample() {
489+
const [isFocused, setIsFocused] = React.useState(false);
490+
491+
return (
492+
<TextInput
493+
defaultValue="Hello World!"
494+
testID="uncontrolled-textinput"
495+
style={isFocused ? styles.focusedUncontrolled : styles.default}
496+
onFocus={() => setIsFocused(true)}
497+
onBlur={() => setIsFocused(false)}
498+
/>
499+
);
500+
}
501+
480502
module.exports = ([
481503
{
482504
title: 'Auto-focus',
@@ -696,4 +718,11 @@ module.exports = ([
696718
);
697719
},
698720
},
721+
{
722+
title: 'Uncontrolled component with layout changes',
723+
name: 'uncontrolledComponent',
724+
render: function (): React.Node {
725+
return <UncontrolledExample />;
726+
},
727+
},
699728
]: Array<RNTesterModuleExample>);

0 commit comments

Comments
 (0)