@samitouri / QOS-React / commits / b392f1e2fc

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 +`;