From ff032dc8576fec0bf6dbeeaf5d53325c2010d284 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 4 Jan 2015 18:21:23 -0500 Subject: [PATCH] Introducing ReactComponentBase base class This is the base class that will be used by ES6 classes. I'm only moving setState and forceUpdate to this base class and the other functions are disabled for modern classes as we're intending to deprecate them. The base classes only have getters that warn if accessed. It's as if they didn't exist. ReactClass now extends ReactComponentBase but also adds the deprecated methods. They are not yet fully deprecated on the ReactClass API. I added some extra tests to composite component which we weren't testing to avoid regressions. I also added some test for ES6 classes. These are not testing the new state initialization process. That's coming in a follow up. --- src/browser/ui/React.js | 2 + src/classic/class/ReactClass.js | 72 +-------- .../__tests__/ReactCompositeComponent-test.js | 80 +++++++++- src/modern/class/ReactComponentBase.js | 127 +++++++++++++++ .../class/__tests__/ReactES6Class-test.js | 150 ++++++++++++++++++ 5 files changed, 363 insertions(+), 68 deletions(-) create mode 100644 src/modern/class/ReactComponentBase.js create mode 100644 src/modern/class/__tests__/ReactES6Class-test.js diff --git a/src/browser/ui/React.js b/src/browser/ui/React.js index c6125cde39f..5cbc45fa96a 100644 --- a/src/browser/ui/React.js +++ b/src/browser/ui/React.js @@ -17,6 +17,7 @@ var DOMPropertyOperations = require('DOMPropertyOperations'); var EventPluginUtils = require('EventPluginUtils'); var ReactChildren = require('ReactChildren'); var ReactComponent = require('ReactComponent'); +var ReactComponentBase = require('ReactComponentBase'); var ReactClass = require('ReactClass'); var ReactContext = require('ReactContext'); var ReactCurrentOwner = require('ReactCurrentOwner'); @@ -57,6 +58,7 @@ var React = { count: ReactChildren.count, only: onlyChild }, + Component: ReactComponentBase, DOM: ReactDOM, PropTypes: ReactPropTypes, initializeTouchEvents: function(shouldUseTouch) { diff --git a/src/classic/class/ReactClass.js b/src/classic/class/ReactClass.js index c9dc4203f7f..f5340e2d083 100644 --- a/src/classic/class/ReactClass.js +++ b/src/classic/class/ReactClass.js @@ -11,6 +11,7 @@ "use strict"; +var ReactComponentBase = require('ReactComponentBase'); var ReactElement = require('ReactElement'); var ReactErrorUtils = require('ReactErrorUtils'); var ReactInstanceMap = require('ReactInstanceMap'); @@ -22,7 +23,6 @@ var invariant = require('invariant'); var keyMirror = require('keyMirror'); var keyOf = require('keyOf'); var monitorCodeUse = require('monitorCodeUse'); -var warning = require('warning'); var MIXINS_KEY = keyOf({mixins: null}); @@ -692,49 +692,11 @@ function bindAutoBindMethods(component) { } /** - * @lends {ReactClass.prototype} + * Add more to the ReactClass base class. These are all legacy features and + * therefore not already part of the modern ReactComponentBase. */ var ReactClassMixin = { - /** - * Sets a subset of the state. Always use this or `replaceState` to mutate - * state. You should treat `this.state` as immutable. - * - * There is no guarantee that `this.state` will be immediately updated, so - * accessing `this.state` after calling this method may return the old value. - * - * There is no guarantee that calls to `setState` will run synchronously, - * as they may eventually be batched together. You can provide an optional - * callback that will be executed when the call to setState is actually - * completed. - * - * @param {object} partialState Next partial state to be merged with state. - * @param {?function} callback Called after state is updated. - * @final - * @protected - */ - setState: function(partialState, callback) { - invariant( - typeof partialState === 'object' || partialState == null, - 'setState(...): takes an object of state variables to update.' - ); - if (__DEV__) { - warning( - partialState != null, - 'setState(...): You passed an undefined or null state object; ' + - 'instead, use forceUpdate().' - ); - } - var internalInstance = ReactInstanceMap.get(this); - invariant( - internalInstance, - 'setState(...): Can only update a mounted or mounting component.' - ); - internalInstance.setState( - partialState, callback && callback.bind(this) - ); - }, - /** * TODO: This will be deprecated because state should always keep a consistent * type signature and the only use case for this, is to avoid that. @@ -743,7 +705,8 @@ var ReactClassMixin = { var internalInstance = ReactInstanceMap.get(this); invariant( internalInstance, - 'replaceState(...): Can only update a mounted or mounting component.' + 'replaceState(...): Can only update a mounted or mounting component. ' + + 'This usually means you called replaceState() on an unmounted component.' ); internalInstance.replaceState( newState, @@ -751,30 +714,6 @@ var ReactClassMixin = { ); }, - /** - * Forces an update. This should only be invoked when it is known with - * certainty that we are **not** in a DOM transaction. - * - * You may want to call this when you know that some deeper aspect of the - * component's state has changed but `setState` was not called. - * - * This will not invoke `shouldUpdateComponent`, but it will invoke - * `componentWillUpdate` and `componentDidUpdate`. - * - * @param {?function} callback Called after update is complete. - * @final - * @protected - */ - forceUpdate: function(callback) { - var internalInstance = ReactInstanceMap.get(this); - invariant( - internalInstance, - 'forceUpdate(...): Can only force an update on mounted or mounting ' + - 'components.' - ); - internalInstance.forceUpdate(callback && callback.bind(this)); - }, - /** * Checks whether or not this composite component is mounted. * @return {boolean} True if mounted, false otherwise. @@ -829,6 +768,7 @@ var ReactClassMixin = { var ReactClassBase = function() {}; assign( ReactClassBase.prototype, + ReactComponentBase.prototype, ReactClassMixin ); diff --git a/src/core/__tests__/ReactCompositeComponent-test.js b/src/core/__tests__/ReactCompositeComponent-test.js index 101f2218e61..5122601b109 100644 --- a/src/core/__tests__/ReactCompositeComponent-test.js +++ b/src/core/__tests__/ReactCompositeComponent-test.js @@ -297,7 +297,8 @@ describe('ReactCompositeComponent', function() { instance.forceUpdate(); }).toThrow( 'Invariant Violation: forceUpdate(...): Can only force an update on ' + - 'mounted or mounting components.' + 'mounted or mounting components. This usually means you called ' + + 'forceUpdate() on an unmounted component.' ); }); @@ -327,10 +328,39 @@ describe('ReactCompositeComponent', function() { instance.setState({ value: 2 }); }).toThrow( 'Invariant Violation: setState(...): Can only update a mounted or ' + - 'mounting component.' + 'mounting component. This usually means you called setState() on an ' + + 'unmounted component.' ); }); + it('should not allow `setState` on unmounting components', function() { + var container = document.createElement('div'); + document.documentElement.appendChild(container); + + var Component = React.createClass({ + getInitialState: function() { + return { value: 0 }; + }, + componentWillUnmount: function() { + expect(() => this.setState({ value: 2 })).toThrow( + 'Invariant Violation: replaceState(...): Cannot update while ' + + 'unmounting component. This usually means you called setState() ' + + 'on an unmounted component.' + ); + }, + render: function() { + return
; + } + }); + + var instance = React.render(, container); + expect(function() { + instance.setState({ value: 1 }); + }).not.toThrow(); + + React.unmountComponentAtNode(container); + }); + it('should not allow `setProps` on unmounted components', function() { var container = document.createElement('div'); document.documentElement.appendChild(container); @@ -623,6 +653,31 @@ describe('ReactCompositeComponent', function() { ); }); + it('only renders once if updated in componentWillReceiveProps', function() { + var renders = 0; + var Component = React.createClass({ + getInitialState: function() { + return { updated: false }; + }, + componentWillReceiveProps: function(props) { + expect(props.update).toBe(1); + this.setState({ updated: true }); + }, + render: function() { + renders++; + return
; + } + }); + + var container = document.createElement('div'); + var instance = React.render(, container); + expect(renders).toBe(1); + expect(instance.state.updated).toBe(false); + React.render(, container); + expect(renders).toBe(2); + expect(instance.state.updated).toBe(true); + }); + it('should update refs if shouldComponentUpdate gives false', function() { var Static = React.createClass({ shouldComponentUpdate: function() { @@ -659,4 +714,25 @@ describe('ReactCompositeComponent', function() { expect(comp.refs.static1.getDOMNode().textContent).toBe('A'); }); + it('should allow access to getDOMNode in componentWillUnmount', function() { + var a = null; + var b = null; + var Component = React.createClass({ + componentDidMount: function() { + a = this.getDOMNode(); + }, + componentWillUnmount: function() { + b = this.getDOMNode(); + }, + render: function() { + return
; + } + }); + var container = document.createElement('div'); + expect(a).toBe(container.firstChild); + React.render(, container); + React.unmountComponentAtNode(container); + expect(a).toBe(b); + }); + }); diff --git a/src/modern/class/ReactComponentBase.js b/src/modern/class/ReactComponentBase.js new file mode 100644 index 00000000000..719adb6768e --- /dev/null +++ b/src/modern/class/ReactComponentBase.js @@ -0,0 +1,127 @@ +/** + * Copyright 2013-2014, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactComponentBase + */ + +"use strict"; + +var ReactInstanceMap = require('ReactInstanceMap'); + +var assign = require('Object.assign'); +var invariant = require('invariant'); +var warning = require('warning'); + +/** + * Base class helpers for the updating state of a component. + */ +function ReactComponentBase(props) { + this.props = props; +} + +/** + * Sets a subset of the state. Always use this to mutate + * state. You should treat `this.state` as immutable. + * + * There is no guarantee that `this.state` will be immediately updated, so + * accessing `this.state` after calling this method may return the old value. + * + * There is no guarantee that calls to `setState` will run synchronously, + * as they may eventually be batched together. You can provide an optional + * callback that will be executed when the call to setState is actually + * completed. + * + * @param {object} partialState Next partial state to be merged with state. + * @param {?function} callback Called after state is updated. + * @final + * @protected + */ +ReactComponentBase.prototype.setState = function(partialState, callback) { + invariant( + typeof partialState === 'object' || partialState == null, + 'setState(...): takes an object of state variables to update.' + ); + if (__DEV__) { + warning( + partialState != null, + 'setState(...): You passed an undefined or null state object; ' + + 'instead, use forceUpdate().' + ); + } + + var internalInstance = ReactInstanceMap.get(this); + invariant( + internalInstance, + 'setState(...): Can only update a mounted or mounting component. ' + + 'This usually means you called setState() on an unmounted ' + + 'component.' + ); + internalInstance.setState( + partialState, callback && callback.bind(this) + ); +}; + +/** + * Forces an update. This should only be invoked when it is known with + * certainty that we are **not** in a DOM transaction. + * + * You may want to call this when you know that some deeper aspect of the + * component's state has changed but `setState` was not called. + * + * This will not invoke `shouldUpdateComponent`, but it will invoke + * `componentWillUpdate` and `componentDidUpdate`. + * + * @param {?function} callback Called after update is complete. + * @final + * @protected + */ +ReactComponentBase.prototype.forceUpdate = function(callback) { + var internalInstance = ReactInstanceMap.get(this); + invariant( + internalInstance, + 'forceUpdate(...): Can only force an update on mounted or mounting ' + + 'components. This usually means you called forceUpdate() on an ' + + 'unmounted component.' + ); + internalInstance.forceUpdate(callback && callback.bind(this)); +}; + +/** + * Deprecated APIs. These APIs used to exist on classic React classes but since + * we would like to deprecate them, we're not going to move them over to this + * modern base class. Instead, we define a getter that warns if it's accessed. + */ +if (__DEV__) { + if (Object.defineProperty) { + var deprecatedAPIs = { + getDOMNode: 'getDOMNode', + isMounted: 'isMounted', + replaceState: 'replaceState', + setProps: 'setProps' + }; + var defineDeprecationWarning = function(methodName, displayName) { + Object.defineProperty(ReactComponentBase.prototype, methodName, { + get: function() { + warning( + false, + '%s(...) is deprecated in plain JavaScript React classes.', + displayName + ); + return undefined; + } + }); + }; + for (var methodName in deprecatedAPIs) { + if (deprecatedAPIs.hasOwnProperty(methodName)) { + defineDeprecationWarning(methodName, deprecatedAPIs[methodName]); + } + } + } +} + +module.exports = ReactComponentBase; diff --git a/src/modern/class/__tests__/ReactES6Class-test.js b/src/modern/class/__tests__/ReactES6Class-test.js new file mode 100644 index 00000000000..1a78f397d54 --- /dev/null +++ b/src/modern/class/__tests__/ReactES6Class-test.js @@ -0,0 +1,150 @@ +/** + * Copyright 2013-2014, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails react-core + */ + +"use strict"; + +var mocks = require('mocks'); + +var React; + +describe('ReactES6Class', function() { + + var container; + var Inner; + var attachedListener = null; + var renderedName = null; + + beforeEach(function() { + React = require('React'); + container = document.createElement(); + attachedListener = null; + renderedName = null; + Inner = class extends React.Component { + getName() { + return this.props.name; + } + render() { + attachedListener = this.props.onClick; + renderedName = this.props.name; + return
; + } + }; + }); + + function test(element, expectedTag, expectedClassName) { + var instance = React.render(element, container); + expect(container.firstChild).not.toBeNull(); + expect(container.firstChild.tagName).toBe(expectedTag); + expect(container.firstChild.className).toBe(expectedClassName); + return instance; + } + + it('preserves the name of the class for use in error messages', function() { + class Foo extends React.Component { } + expect(Foo.name).toBe('Foo'); + }); + + it('throws if no render function is defined', function() { + class Foo extends React.Component { } + expect(() => React.render(, container)).toThrow(); + }); + + it('renders a simple stateless component with prop', function() { + class Foo { + render() { + return ; + } + } + test(, 'DIV', 'foo'); + test(, 'DIV', 'bar'); + }); + + it('renders using forceUpdate even when there is no state', function() { + class Foo extends React.Component { + constructor(props) { + this.mutativeValue = props.initialValue; + } + handleClick() { + this.mutativeValue = 'bar'; + this.forceUpdate(); + } + render() { + return ( + + ); + } + } + test(, 'DIV', 'foo'); + attachedListener(); + expect(renderedName).toBe('bar'); + }); + + it('should throw AND warn when trying to access classic APIs', function() { + spyOn(console, 'warn'); + var instance = test(, 'DIV', 'foo'); + expect(() => instance.getDOMNode()).toThrow(); + expect(() => instance.replaceState({})).toThrow(); + expect(() => instance.isMounted()).toThrow(); + expect(() => instance.setProps({ name: 'bar' })).toThrow(); + expect(console.warn.calls.length).toBe(4); + expect(console.warn.calls[0].args[0]).toContain( + 'getDOMNode(...) is deprecated in plain JavaScript React classes' + ); + expect(console.warn.calls[1].args[0]).toContain( + 'replaceState(...) is deprecated in plain JavaScript React classes' + ); + expect(console.warn.calls[2].args[0]).toContain( + 'isMounted(...) is deprecated in plain JavaScript React classes' + ); + expect(console.warn.calls[3].args[0]).toContain( + 'setProps(...) is deprecated in plain JavaScript React classes' + ); + }); + + it('supports this.context passed via getChildContext', function() { + class Bar { + render() { + return
; + } + } + Bar.contextTypes = { bar: React.PropTypes.string }; + class Foo { + getChildContext() { + return { bar: 'bar-through-context' }; + } + render() { + return ; + } + } + Foo.childContextTypes = { bar: React.PropTypes.string }; + test(, 'DIV', 'bar-through-context'); + }); + + it('supports classic refs', function() { + class Foo { + render() { + return ; + } + } + var instance = test(, 'DIV', 'foo'); + expect(instance.refs.inner.getName()).toBe('foo'); + }); + + it('supports drilling through to the DOM using findDOMNode', function() { + var instance = test(, 'DIV', 'foo'); + var node = React.findDOMNode(instance); + expect(node).toBe(container.firstChild); + }); + +});