Add warnings when accessing properties/methods on unmounted components
This creates a membrane around the React component prototype. It warns if you try to access properties on the component before it's unmounted. Before it's mounted, it should be considered a descriptor and not an actual instance. The workaround, for unknown types, is to access the constructor using component.type which has static methods on it.
Sebastian Markbage committed
Feb 11, 2014 at 09:00 UTC
fc2805fe03889a8033bb5f651e7b6937b45d33df
7 files changed
+221
-19
src/browser/ReactDOM.js
+6
@@ -51,6 +51,12 @@ function createDOMComponentClass(tag, omitClose) {
51
return instance;
52
};
53
54
+ // Expose the constructor on the ConvenienceConstructor and prototype so that
55
+ // it can be easily easily accessed on descriptors.
56
+ // E.g. <div />.type === div.type
57
+ ConvenienceConstructor.type = Constructor;
58
+ Constructor.prototype.type = Constructor;
59
+
60
Constructor.ConvenienceConstructor = ConvenienceConstructor;
61
ConvenienceConstructor.componentConstructor = Constructor;
62
return ConvenienceConstructor;
src/browser/ReactTextComponent.js
+5
@@ -91,4 +91,9 @@ mixInto(ReactTextComponent, {
91
92
});
93
94
+// Expose the constructor on itself and the prototype for consistency with other
95
+// descriptors.
96
+ReactTextComponent.type = ReactTextComponent;
97
+ReactTextComponent.prototype.type = ReactTextComponent;
98
+
99
module.exports = ReactTextComponent;
src/core/ReactComponent.js
+11
-4
@@ -183,10 +183,17 @@ var ReactComponent = {
183
* @final
184
*/
185
isValidComponent: function(object) {
186
- return !!(
187
- object &&
188
- typeof object.mountComponentIntoNode === 'function' &&
189
- typeof object.receiveComponent === 'function'
186
+ if (!object || !object.type || !object.type.prototype) {
187
+ return false;
188
+ }
189
+ // This is the safer way of duck checking the type of instance this is.
190
+ // The object can be a generic descriptor but the type property refers to
191
+ // the constructor and it's prototype can be used to inspect the type that
192
+ // will actually get mounted.
193
+ var prototype = object.type.prototype;
194
+ return (
195
+ typeof prototype.mountComponentIntoNode === 'function' &&
196
+ typeof prototype.receiveComponent === 'function'
197
);
198
},
199
src/core/ReactCompositeComponent.js
+124
@@ -584,6 +584,118 @@ function createChainedFunction(one, two) {
584
};
585
}
586
587
+if (__DEV__) {
588
+
589
+ var unmountedPropertyWhitelist = {
590
+ constructor: true,
591
+ construct: true,
592
+ isOwnedBy: true, // should be deprecated but can have code mod (internal)
593
+ mountComponent: true,
594
+ mountComponentIntoNode: true,
595
+ props: true,
596
+ type: true,
597
+ _checkPropTypes: true,
598
+ _mountComponentIntoNode: true,
599
+ _processContext: true
600
+ };
601
+
602
+ var hasWarnedOnComponentType = {};
603
+
604
+ var warnIfUnmounted = function(instance, key) {
605
+ if (instance.__hasBeenMounted) {
606
+ return;
607
+ }
608
+ var name = instance.constructor.displayName || 'Unknown';
609
+ var owner = ReactCurrentOwner.current;
610
+ var ownerName = (owner && owner.constructor.displayName) || 'Unknown';
611
+ var warningKey = key + '|' + name + '|' + ownerName;
612
+ if (hasWarnedOnComponentType.hasOwnProperty(warningKey)) {
613
+ // We have already warned for this combination. Skip it this time.
614
+ return;
615
+ }
616
+ hasWarnedOnComponentType[warningKey] = true;
617
+
618
+ var context = owner ? ' in ' + ownerName + '.' : ' at the top level.';
619
+ var staticMethodExample = '<' + name + ' />.type.' + key + '(...)';
620
+
621
+ console.warn(
622
+ 'Invalid access to component property "' + key + '" on ' + name +
623
+ context + ' See http://fb.me/react-warning-descriptors .' +
624
+ ' Use a static method instead: ' + staticMethodExample
625
+ );
626
+ };
627
+
628
+ var defineMembraneProperty = function(membrane, prototype, key) {
629
+ Object.defineProperty(membrane, key, {
630
+
631
+ configurable: false,
632
+ enumerable: true,
633
+
634
+ get: function() {
635
+ if (this !== membrane) {
636
+ // When this is accessed through a prototype chain we need to check if
637
+ // this component was mounted.
638
+ warnIfUnmounted(this, key);
639
+ }
640
+ return prototype[key];
641
+ },
642
+
643
+ set: function(value) {
644
+ if (this !== membrane) {
645
+ // When this is accessed through a prototype chain, we first check if
646
+ // this component was mounted. Then we define a value on "this"
647
+ // instance, effectively disabling the membrane on that prototype
648
+ // chain.
649
+ warnIfUnmounted(this, key);
650
+ Object.defineProperty(this, key, {
651
+ enumerable: true,
652
+ configurable: true,
653
+ writable: true,
654
+ value: value
655
+ });
656
+ } else {
657
+ // Otherwise, this should modify the prototype
658
+ prototype[key] = value;
659
+ }
660
+ }
661
+
662
+ });
663
+ };
664
+
665
+ /**
666
+ * Creates a membrane prototype which wraps the original prototype. If any
667
+ * property is accessed in an unmounted state, a warning is issued.
668
+ *
669
+ * @param {object} prototype Original prototype.
670
+ * @return {object} The membrane prototype.
671
+ * @private
672
+ */
673
+ var createMountWarningMembrane = function(prototype) {
674
+ try {
675
+ var membrane = Object.create(prototype);
676
+ for (var key in prototype) {
677
+ if (unmountedPropertyWhitelist.hasOwnProperty(key)) {
678
+ continue;
679
+ }
680
+ defineMembraneProperty(membrane, prototype, key);
681
+ }
682
+
683
+ membrane.mountComponent = function() {
684
+ this.__hasBeenMounted = true;
685
+ return prototype.mountComponent.apply(this, arguments);
686
+ };
687
+
688
+ return membrane;
689
+ } catch(x) {
690
+ // In IE8 define property will fail on non-DOM objects. If anything in
691
+ // the membrane creation fails, we'll bail out and just use the prototype
692
+ // without warnings.
693
+ return prototype;
694
+ }
695
+ };
696
+
697
+}
698
+
699
/**
700
* `ReactCompositeComponent` maintains an auxiliary life cycle state in
701
* `this._compositeLifeCycleState` (which can be null).
@@ -1307,6 +1419,14 @@ var ReactCompositeComponent = {
1419
}
1420
}
1421
1422
+ // Expose the convience constructor on the prototype so that it can be
1423
+ // easily accessed on descriptors. E.g. <Foo />.type === Foo.type and for
1424
+ // static methods like <Foo />.type.staticMethod();
1425
+ // This should not be named constructor since this may not be the function
1426
+ // that created the descriptor, and it may not even be a constructor.
1427
+ ConvenienceConstructor.type = Constructor;
1428
+ Constructor.prototype.type = Constructor;
1429
+
1430
// Reduce time spent doing lookups by setting these on the prototype.
1431
for (var methodName in ReactCompositeComponentInterface) {
1432
if (!Constructor.prototype[methodName]) {
@@ -1314,6 +1434,10 @@ var ReactCompositeComponent = {
1434
}
1435
}
1436
1437
+ if (__DEV__) {
1438
+ Constructor.prototype = createMountWarningMembrane(Constructor.prototype);
1439
+ }
1440
+
1441
return ConvenienceConstructor;
1442
},
1443
src/core/__tests__/ReactComponent-test.js
+2
-3
@@ -126,10 +126,9 @@ describe('ReactComponent', function() {
126
}
127
});
128
129
- var instance = <Component />;
129
+ var descriptor = <Component />;
130
131
- expect(instance.isMounted()).toBeFalsy();
132
- ReactTestUtils.renderIntoDocument(instance);
131
+ var instance = ReactTestUtils.renderIntoDocument(descriptor);
132
expect(instance.isMounted()).toBeTruthy();
133
});
134
src/core/__tests__/ReactCompositeComponent-test.js
+60
-2
@@ -170,6 +170,7 @@ describe('ReactCompositeComponent', function() {
170
171
// These are controversial assertions for now, they just exist
172
// because existing code depends on these assumptions.
173
+ // These are expected to log a warning. This use case will be deprecated.
174
expect(function() {
175
instance.methodToBeExplicitlyBound.bind(instance)();
176
}).not.toThrow();
@@ -183,9 +184,9 @@ describe('ReactCompositeComponent', function() {
184
// Next, prove that once mounted, the scope is bound correctly to the actual
185
// component.
186
ReactTestUtils.renderIntoDocument(instance);
186
- expect(console.warn.argsForCall.length).toBe(0);
187
+ expect(console.warn.argsForCall.length).toBe(3);
188
var explicitlyBound = instance.methodToBeExplicitlyBound.bind(instance);
188
- expect(console.warn.argsForCall.length).toBe(1);
189
+ expect(console.warn.argsForCall.length).toBe(4);
190
var autoBound = instance.methodAutoBound;
191
var explicitlyNotBound = instance.methodExplicitlyNotBound;
192
@@ -1105,4 +1106,61 @@ describe('ReactCompositeComponent', function() {
1106
'use a component class as a mixin. Instead, just use a regular object.'
1107
);
1108
});
1109
+
1110
+ it('should warn if an umounted component is touched', function() {
1111
+ spyOn(console, 'warn');
1112
+
1113
+ var ComponentClass = React.createClass({
1114
+ getInitialState: function() {
1115
+ return {valueToReturn: 'hi'};
1116
+ },
1117
+ someMethod: function() {
1118
+ return this;
1119
+ },
1120
+ someOtherMethod: function() {
1121
+ return this;
1122
+ },
1123
+ render: function() {
1124
+ return <div></div>;
1125
+ }
1126
+ });
1127
+
1128
+ var descriptor = <ComponentClass />;
1129
+ var instance = ReactTestUtils.renderIntoDocument(descriptor);
1130
+ instance.someMethod();
1131
+ expect(console.warn.argsForCall.length).toBe(0);
1132
+
1133
+ var unmountedInstance = <ComponentClass />;
1134
+ var result = unmountedInstance.someMethod();
1135
+ expect(console.warn.argsForCall.length).toBe(1);
1136
+ expect(result).toBe(unmountedInstance);
1137
+
1138
+ var unmountedInstance2 = <ComponentClass />;
1139
+ unmountedInstance2.someOtherMethod = 'override';
1140
+ expect(console.warn.argsForCall.length).toBe(2);
1141
+ expect(unmountedInstance2.someOtherMethod).toBe('override');
1142
+ });
1143
+
1144
+ it('should allow static methods called using type property', function() {
1145
+ spyOn(console, 'warn');
1146
+
1147
+ var ComponentClass = React.createClass({
1148
+ statics: {
1149
+ someStaticMethod: function() {
1150
+ return 'someReturnValue';
1151
+ }
1152
+ },
1153
+ getInitialState: function() {
1154
+ return {valueToReturn: 'hi'};
1155
+ },
1156
+ render: function() {
1157
+ return <div></div>;
1158
+ }
1159
+ });
1160
+
1161
+ var descriptor = <ComponentClass />;
1162
+ expect(descriptor.type.someStaticMethod()).toBe('someReturnValue');
1163
+ expect(console.warn.argsForCall.length).toBe(0);
1164
+ });
1165
+
1166
});
src/test/ReactTestUtils.js
+13
-10
@@ -51,11 +51,10 @@ var ReactTestUtils = {
51
return React.renderComponent(instance, div);
52
},
53
54
- isComponentOfType: function(inst, type) {
55
- return !!(
56
- inst &&
54
+ isComponentOfType: function(inst, convenienceConstructor) {
55
+ return (
56
ReactComponent.isValidComponent(inst) &&
58
- inst.constructor === type.componentConstructor
57
+ inst.type === convenienceConstructor.type
58
);
59
},
60
@@ -66,12 +65,16 @@ var ReactTestUtils = {
65
},
66
67
isCompositeComponent: function(inst) {
69
- return !!(
70
- inst &&
71
- ReactComponent.isValidComponent(inst) &&
72
- typeof inst.render === 'function' &&
73
- typeof inst.setState === 'function' &&
74
- typeof inst.updateComponent === 'function'
68
+ if (!ReactComponent.isValidComponent(inst)) {
69
+ return false;
70
+ }
71
+ // We check the prototype of the type that will get mounted, not the
72
+ // instance itself. This is a future proof way of duck typing.
73
+ var prototype = inst.type.prototype;
74
+ return (
75
+ typeof prototype.render === 'function' &&
76
+ typeof prototype.setState === 'function' &&
77
+ typeof prototype.updateComponent === 'function'
78
);
79
},
80