Fix errors in React Native children management (#9449)
* Fix errors in React Native children management - Support using appendChild to move an existing child (Fiber does this, but we were assuming all children here were new; Yoga throws if you insert a child that already has a parent) - Calculate beforeChildIndex after removing old child (previously, off by one when the new position is later than the old position) * Add better children management tests for RN
Ben Alpert committed
Apr 18, 2017 at 17:31 UTC
b392f1e2fcc892a0582a173e517d429f277562aa
6 files changed
+244
-13
src/__mocks__/UIManager.js
+130
-3
@@ -11,12 +11,139 @@
11
12
// Mock of the Native Hooks
13
14
+const ReactNativeTagHandles = require('ReactNativeTagHandles');
15
+const invariant = require('fbjs/lib/invariant');
16
+
17
+// Map of viewTag -> {children: [childTag], parent: ?parentTag}
18
+const roots = [];
19
+let views = new Map();
20
+
21
+function autoCreateRoot(tag) {
22
+ // Seriously, this is how we distinguish roots in RN.
23
+ if (!views.has(tag) && ReactNativeTagHandles.reactTagIsNativeTopRootID(tag)) {
24
+ roots.push(tag);
25
+ views.set(tag, {
26
+ children: [],
27
+ parent: null,
28
+ props: {},
29
+ viewName: '<native root>',
30
+ });
31
+ }
32
+}
33
+
34
+function insertSubviewAtIndex(parent, child, index) {
35
+ const parentInfo = views.get(parent);
36
+ const childInfo = views.get(child);
37
+ invariant(
38
+ childInfo.parent === null,
39
+ 'Inserting view %s %s which already has parent',
40
+ child,
41
+ JSON.stringify(childInfo.props),
42
+ );
43
+ invariant(
44
+ 0 <= index && index <= parentInfo.children.length,
45
+ 'Invalid index %s for children %s',
46
+ index,
47
+ parentInfo.children
48
+ );
49
+ parentInfo.children.splice(index, 0, child);
50
+ childInfo.parent = parent;
51
+}
52
+
53
+function removeChild(parent, child) {
54
+ const parentInfo = views.get(parent);
55
+ const childInfo = views.get(child);
56
+ const index = parentInfo.children.indexOf(child);
57
+ invariant(index >= 0, 'Missing view %s during removal', child);
58
+ parentInfo.children.splice(index, 1);
59
+ childInfo.parent = null;
60
+}
61
+
62
var RCTUIManager = {
63
+ __dumpHierarchyForJestTestsOnly: function() {
64
+ return roots.map((tag) => dumpSubtree(tag, 0)).join('\n');
65
+
66
+ function dumpSubtree(tag, indent) {
67
+ const info = views.get(tag);
68
+ let out = '';
69
+ out += ' '.repeat(indent) + info.viewName + ' ' + JSON.stringify(info.props);
70
+ for (const child of info.children) {
71
+ out += '\n' + dumpSubtree(child, indent + 2);
72
+ }
73
+ return out;
74
+ }
75
+ },
76
clearJSResponder: jest.fn(),
16
- createView: jest.fn(),
77
+ createView: jest.fn(function createView(reactTag, viewName, rootTag, props) {
78
+ invariant(
79
+ !views.has(reactTag),
80
+ 'Created two native views with tag %s',
81
+ reactTag,
82
+ );
83
+ views.set(reactTag, {
84
+ children: [],
85
+ parent: null,
86
+ props: props,
87
+ viewName: viewName,
88
+ });
89
+ }),
90
setJSResponder: jest.fn(),
18
- setChildren: jest.fn(),
19
- manageChildren: jest.fn(),
91
+ setChildren: jest.fn(function setChildren(parentTag, reactTags) {
92
+ autoCreateRoot(parentTag);
93
+ // Native doesn't actually check this but it seems like a good idea
94
+ invariant(
95
+ views.get(parentTag).children.length === 0,
96
+ 'Calling .setChildren on nonempty view %s',
97
+ parentTag,
98
+ );
99
+ // This logic ported from iOS (RCTUIManager.m)
100
+ reactTags.forEach((tag, i) => {
101
+ insertSubviewAtIndex(parentTag, tag, i);
102
+ });
103
+ }),
104
+ manageChildren: jest.fn(function manageChildren(
105
+ parentTag,
106
+ moveFromIndices = [],
107
+ moveToIndices = [],
108
+ addChildReactTags = [],
109
+ addAtIndices = [],
110
+ removeAtIndices = [],
111
+ ) {
112
+ autoCreateRoot(parentTag);
113
+ // This logic ported from iOS (RCTUIManager.m)
114
+ invariant(
115
+ moveFromIndices.length === moveToIndices.length,
116
+ 'Mismatched move indices %s and %s',
117
+ moveFromIndices,
118
+ moveToIndices,
119
+ );
120
+ invariant(
121
+ addChildReactTags.length === addAtIndices.length,
122
+ 'Mismatched add indices %s and %s',
123
+ addChildReactTags,
124
+ addAtIndices,
125
+ );
126
+ const parentInfo = views.get(parentTag);
127
+ const permanentlyRemovedChildren = removeAtIndices.map((index) => parentInfo.children[index]);
128
+ const temporarilyRemovedChildren = moveFromIndices.map((index) => parentInfo.children[index]);
129
+ permanentlyRemovedChildren.forEach((tag) => removeChild(parentTag, tag));
130
+ temporarilyRemovedChildren.forEach((tag) => removeChild(parentTag, tag));
131
+ permanentlyRemovedChildren.forEach((tag) => {
132
+ views.delete(tag);
133
+ });
134
+ // List of [index, tag]
135
+ const indicesToInsert = [];
136
+ temporarilyRemovedChildren.forEach((tag, i) => {
137
+ indicesToInsert.push([moveToIndices[i], temporarilyRemovedChildren[i]]);
138
+ });
139
+ addChildReactTags.forEach((tag, i) => {
140
+ indicesToInsert.push([addAtIndices[i], addChildReactTags[i]]);
141
+ });
142
+ indicesToInsert.sort((a, b) => a[0] - b[0]);
143
+ for (const [i, tag] of indicesToInsert) {
144
+ insertSubviewAtIndex(parentTag, tag, i);
145
+ }
146
+ }),
147
updateView: jest.fn(),
148
removeSubviewsFromContainerWithID: jest.fn(),
149
replaceExistingNonRootView: jest.fn(),
src/renderers/native/ReactNativeFiber.js
+27
-10
@@ -82,16 +82,32 @@ const NativeRenderer = ReactFiberReconciler({
82
} else {
83
const children = parentInstance._children;
84
85
- children.push(child);
85
+ const index = children.indexOf(child);
86
87
- UIManager.manageChildren(
88
- parentInstance._nativeTag, // containerTag
89
- [], // moveFromIndices
90
- [], // moveToIndices
91
- [childTag], // addChildReactTags
92
- [children.length - 1], // addAtIndices
93
- [], // removeAtIndices
94
- );
87
+ if (index >= 0) {
88
+ children.splice(index, 1);
89
+ children.push(child);
90
+
91
+ UIManager.manageChildren(
92
+ parentInstance._nativeTag, // containerTag
93
+ [index], // moveFromIndices
94
+ [children.length - 1], // moveToIndices
95
+ [], // addChildReactTags
96
+ [], // addAtIndices
97
+ [], // removeAtIndices
98
+ );
99
+ } else {
100
+ children.push(child);
101
+
102
+ UIManager.manageChildren(
103
+ parentInstance._nativeTag, // containerTag
104
+ [], // moveFromIndices
105
+ [], // moveToIndices
106
+ [childTag], // addChildReactTags
107
+ [children.length - 1], // addAtIndices
108
+ [], // removeAtIndices
109
+ );
110
+ }
111
}
112
},
113
@@ -264,12 +280,12 @@ const NativeRenderer = ReactFiberReconciler({
280
281
const children = (parentInstance: any)._children;
282
267
- const beforeChildIndex = children.indexOf(beforeChild);
283
const index = children.indexOf(child);
284
285
// Move existing child or add new child?
286
if (index >= 0) {
287
children.splice(index, 1);
288
+ const beforeChildIndex = children.indexOf(beforeChild);
289
children.splice(beforeChildIndex, 0, child);
290
291
UIManager.manageChildren(
@@ -281,6 +297,7 @@ const NativeRenderer = ReactFiberReconciler({
297
[], // removeAtIndices
298
);
299
} else {
300
+ const beforeChildIndex = children.indexOf(beforeChild);
301
children.splice(beforeChildIndex, 0, child);
302
303
const childTag = typeof child === 'number' ? child : child._nativeTag;
src/renderers/native/__tests__/ReactNativeEvents-test.js
+1
@@ -58,6 +58,7 @@ it('handles events', () => {
58
1,
59
);
60
61
+ expect(UIManager.__dumpHierarchyForJestTestsOnly()).toMatchSnapshot();
62
expect(UIManager.createView.mock.calls.length).toBe(2);
63
64
// Don't depend on the order of createView() calls.
src/renderers/native/__tests__/ReactNativeMount-test.js
+28
@@ -80,4 +80,32 @@ describe('ReactNative', () => {
80
expect(a).toBe(b);
81
expect(a).toBe(c);
82
});
83
+
84
+ it('renders and reorders children', () => {
85
+ var View = createReactNativeComponentClass({
86
+ validAttributes: {title: true},
87
+ uiViewClassName: 'View',
88
+ });
89
+
90
+ class Component extends React.Component {
91
+ render() {
92
+ var chars = this.props.chars.split('');
93
+ return (
94
+ <View>
95
+ {chars.map(text => <View key={text} title={text} />)}
96
+ </View>
97
+ );
98
+ }
99
+ }
100
+
101
+ // Mini multi-child stress test: lots of reorders, some adds, some removes.
102
+ var before = 'abcdefghijklmnopqrst';
103
+ var after = 'mxhpgwfralkeoivcstzy';
104
+
105
+ ReactNative.render(<Component chars={before} />, 11);
106
+ expect(UIManager.__dumpHierarchyForJestTestsOnly()).toMatchSnapshot();
107
+
108
+ ReactNative.render(<Component chars={after} />, 11);
109
+ expect(UIManager.__dumpHierarchyForJestTestsOnly()).toMatchSnapshot();
110
+ });
111
});
src/renderers/native/__tests__/__snapshots__/ReactNativeEvents-test.js.snap
new
+7
@@ -0,0 +1,7 @@
1
+// Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+exports[`handles events 1`] = `
4
+"<native root> {}
5
+ View {\\"foo\\":\\"outer\\"}
6
+ View {\\"foo\\":\\"inner\\"}"
7
+`;
src/renderers/native/__tests__/__snapshots__/ReactNativeMount-test.js.snap
new
+51
@@ -0,0 +1,51 @@
1
+// Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+exports[`ReactNative renders and reorders children 1`] = `
4
+"<native root> {}
5
+ View null
6
+ View {\\"title\\":\\"a\\"}
7
+ View {\\"title\\":\\"b\\"}
8
+ View {\\"title\\":\\"c\\"}
9
+ View {\\"title\\":\\"d\\"}
10
+ View {\\"title\\":\\"e\\"}
11
+ View {\\"title\\":\\"f\\"}
12
+ View {\\"title\\":\\"g\\"}
13
+ View {\\"title\\":\\"h\\"}
14
+ View {\\"title\\":\\"i\\"}
15
+ View {\\"title\\":\\"j\\"}
16
+ View {\\"title\\":\\"k\\"}
17
+ View {\\"title\\":\\"l\\"}
18
+ View {\\"title\\":\\"m\\"}
19
+ View {\\"title\\":\\"n\\"}
20
+ View {\\"title\\":\\"o\\"}
21
+ View {\\"title\\":\\"p\\"}
22
+ View {\\"title\\":\\"q\\"}
23
+ View {\\"title\\":\\"r\\"}
24
+ View {\\"title\\":\\"s\\"}
25
+ View {\\"title\\":\\"t\\"}"
26
+`;
27
+
28
+exports[`ReactNative renders and reorders children 2`] = `
29
+"<native root> {}
30
+ View null
31
+ View {\\"title\\":\\"m\\"}
32
+ View {\\"title\\":\\"x\\"}
33
+ View {\\"title\\":\\"h\\"}
34
+ View {\\"title\\":\\"p\\"}
35
+ View {\\"title\\":\\"g\\"}
36
+ View {\\"title\\":\\"w\\"}
37
+ View {\\"title\\":\\"f\\"}
38
+ View {\\"title\\":\\"r\\"}
39
+ View {\\"title\\":\\"a\\"}
40
+ View {\\"title\\":\\"l\\"}
41
+ View {\\"title\\":\\"k\\"}
42
+ View {\\"title\\":\\"e\\"}
43
+ View {\\"title\\":\\"o\\"}
44
+ View {\\"title\\":\\"i\\"}
45
+ View {\\"title\\":\\"v\\"}
46
+ View {\\"title\\":\\"c\\"}
47
+ View {\\"title\\":\\"s\\"}
48
+ View {\\"title\\":\\"t\\"}
49
+ View {\\"title\\":\\"z\\"}
50
+ View {\\"title\\":\\"y\\"}"
51
+`;