diff --git a/README.md b/README.md index bfa32da..b670284 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ## What is this project? -React version 17 will deprecate several of the class component API lifecycles: `componentWillMount`, `componentWillReceiveProps`, and `componentWillUpdate`. (See [React RFC 6](https://github.com/reactjs/rfcs/pull/6) for more information about this decision.) +React version 17 will deprecate several of the class component API lifecycles: `componentWillMount`, `componentWillReceiveProps`, and `componentWillUpdate`. (Read the [Update on Async rendering blog post](https://deploy-preview-596--reactjs.netlify.com/blog/2018/03/15/update-on-async-rendering.html) to learn more about why.) A couple of new lifecycles are also being added to better support [async rendering mode](https://reactjs.org/blog/2018/03/01/sneak-peek-beyond-react-16.html). -This would typically require any third party libraries dependent on those lifecycles to release a new major version in order to adhere to semver. However, the `react-lifecycles-compat` polyfill offers a way to remain compatible with older versions of React (0.14.9+). 🎉😎 +Typically, this type of change would require third party libraries to release a new major version in order to adhere to semver. However, the `react-lifecycles-compat` polyfill offers a way to use the new lifecycles with older versions of React as well (0.14.9+) so no breaking release is required. This enables shared libraries to support both older and newer versions of React simultaneously. ## How can I use the polyfill @@ -17,58 +17,31 @@ yarn add react-lifecycles-compat npm install react-lifecycles-compat --save ``` -Next, update your component to use the new static lifecycle, `getDerivedStateFromProps`. For example: -```js -// Before -class ExampleComponent extends React.Component { - state = { - derivedData: computeDerivedState(this.props) - }; - - componentWillReceiveProps(nextProps) { - if (this.props.someValue !== nextProps.someValue) { - this.setState({ - derivedData: computeDerivedState(nextProps) - }); - } - } -} - -// After -class ExampleComponent extends React.Component { - // Initialize state in constructor, - // Or with a property initializer. - state = {}; - - static getDerivedStateFromProps(nextProps, prevState) { - if (prevState.someMirroredValue !== nextProps.someValue) { - return { - derivedData: computeDerivedState(nextProps), - someMirroredValue: nextProps.someValue - }; - } - - // Return null to indicate no change to state. - return null; - } -} -``` +Next, update your component and replace any of the deprecated lifecycles with new ones introduced with React 16.3. (Refer to the React docs for [examples of how to use the new lifecycles](https://deploy-preview-596--reactjs.netlify.com/blog/2018/03/15/update-on-async-rendering.html).) -Lastly, use the polyfill to make your component backwards compatible with older versions of React: +Lastly, use the polyfill to make the new lifecycles work with older versions of React: ```js import React from 'react'; import polyfill from 'react-lifecycles-compat'; class ExampleComponent extends React.Component { - static getDerivedStateFromProps(nextProps, prevState) { - // ... - } - // ... } -// Polyfill your component to work with older versions of React: +// Polyfill your component so the new lifecycles will work with older versions of React: polyfill(ExampleComponent); export default ExampleComponent; -``` \ No newline at end of file +``` + +## Which lifecycles are supported? + +Currently, this polyfill supports [static `getDerivedStateFromProps`](https://deploy-preview-587--reactjs.netlify.com/docs/react-component.html#static-getderivedstatefromprops) and [`getSnapshotBeforeUpdate`](https://deploy-preview-587--reactjs.netlify.com/docs/react-component.html#getsnapshotbeforeupdate)- both introduced in version 16.3. + +## Validation + +Note that in order for the polyfill to work, none of the following lifecycles can be defined by your component: `componentWillMount`, `componentWillReceiveProps`, or `componentWillUpdate`. + +Note also that if your component contains `getSnapshotBeforeUpdate`, `componentDidUpdate` must be defined as well. + +An error will be thrown if any of the above conditions are not met. \ No newline at end of file diff --git a/index.js b/index.js index ff83a5b..2e09d61 100644 --- a/index.js +++ b/index.js @@ -26,10 +26,27 @@ function componentWillReceiveProps(nextProps) { } } +function componentWillUpdate(nextProps, nextState) { + try { + var prevProps = this.props; + var prevState = this.state; + this.props = nextProps; + this.state = nextState; + this.__reactInternalSnapshot = this.getSnapshotBeforeUpdate( + prevProps, + prevState + ); + } finally { + this.props = prevProps; + this.state = prevState; + } +} + // React may warn about cWM/cWRP/cWU methods being deprecated. // Add a flag to suppress these warnings for this special case. componentWillMount.__suppressDeprecationWarning = true; componentWillReceiveProps.__suppressDeprecationWarning = true; +componentWillUpdate.__suppressDeprecationWarning = true; module.exports = function polyfill(Component) { if (!Component.prototype || !Component.prototype.isReactComponent) { @@ -38,12 +55,14 @@ module.exports = function polyfill(Component) { if (typeof Component.getDerivedStateFromProps === 'function') { if (typeof Component.prototype.componentWillMount === 'function') { - throw new Error('Cannot polyfill if componentWillMount already exists'); + throw new Error( + 'Cannot polyfill getDerivedStateFromProps() for components that define componentWillMount()' + ); } else if ( typeof Component.prototype.componentWillReceiveProps === 'function' ) { throw new Error( - 'Cannot polyfill if componentWillReceiveProps already exists' + 'Cannot polyfill getDerivedStateFromProps() for components that define componentWillReceiveProps()' ); } @@ -51,5 +70,34 @@ module.exports = function polyfill(Component) { Component.prototype.componentWillReceiveProps = componentWillReceiveProps; } + if (typeof Component.prototype.getSnapshotBeforeUpdate === 'function') { + if (typeof Component.prototype.componentWillUpdate === 'function') { + throw new Error( + 'Cannot polyfill getSnapshotBeforeUpdate() for components that define componentWillUpdate()' + ); + } + if (typeof Component.prototype.componentDidUpdate !== 'function') { + throw new Error( + 'Cannot polyfill getSnapshotBeforeUpdate() for components that do not define componentDidUpdate() on the prototype' + ); + } + + Component.prototype.componentWillUpdate = componentWillUpdate; + + var componentDidUpdate = Component.prototype.componentDidUpdate; + + Component.prototype.componentDidUpdate = function componentDidUpdatePolyfill( + prevProps, + prevState + ) { + componentDidUpdate.call( + this, + prevProps, + prevState, + this.__reactInternalSnapshot + ); + }; + } + return Component; }; diff --git a/test.js b/test.js index 221b598..9184990 100644 --- a/test.js +++ b/test.js @@ -19,6 +19,15 @@ Object.entries(POLYFILLS).forEach(([name, polyfill]) => { const ReactDOM = require(basePath + 'react-dom'); describe(`react@${version}`, () => { + beforeAll(() => { + jest.spyOn(console, 'error'); + global.console.error.mockImplementation(() => {}); + }); + + afterAll(() => { + global.console.error.mockRestore(); + }); + it('should initialize and update state correctly', () => { class ClassComponent extends React.Component { constructor(props) { @@ -54,6 +63,8 @@ Object.entries(POLYFILLS).forEach(([name, polyfill]) => { }); it('should support create-react-class components', () => { + let componentDidUpdateCalled = false; + const CRCComponent = createReactClass({ statics: { getDerivedStateFromProps(nextProps, prevState) { @@ -65,6 +76,17 @@ Object.entries(POLYFILLS).forEach(([name, polyfill]) => { getInitialState() { return {count: 1}; }, + getSnapshotBeforeUpdate(prevProps, prevState) { + return prevState.count * 2 + this.state.count * 3; + }, + componentDidUpdate(prevProps, prevState, snapshot) { + expect(prevProps).toEqual({incrementBy: 2}); + expect(prevState).toEqual({count: 3}); + expect(this.props).toEqual({incrementBy: 3}); + expect(this.state).toEqual({count: 6}); + expect(snapshot).toBe(24); + componentDidUpdateCalled = true; + }, render() { return React.createElement('div', null, this.state.count); }, @@ -79,6 +101,7 @@ Object.entries(POLYFILLS).forEach(([name, polyfill]) => { ); expect(container.textContent).toBe('3'); + expect(componentDidUpdateCalled).toBe(false); ReactDOM.render( React.createElement(CRCComponent, {incrementBy: 3}), @@ -86,6 +109,67 @@ Object.entries(POLYFILLS).forEach(([name, polyfill]) => { ); expect(container.textContent).toBe('6'); + expect(componentDidUpdateCalled).toBe(true); + }); + + it('should support class components', () => { + let componentDidUpdateCalled = false; + + class Component extends React.Component { + constructor(props) { + super(props); + this.state = {count: 1}; + this.setRef = ref => { + this.divRef = ref; + }; + } + static getDerivedStateFromProps(nextProps, prevState) { + return { + count: prevState.count + nextProps.incrementBy, + }; + } + getSnapshotBeforeUpdate(prevProps, prevState) { + expect(prevProps).toEqual({incrementBy: 2}); + expect(prevState).toEqual({count: 3}); + return this.divRef.textContent; + } + componentDidUpdate(prevProps, prevState, snapshot) { + expect(prevProps).toEqual({incrementBy: 2}); + expect(prevState).toEqual({count: 3}); + expect(this.props).toEqual({incrementBy: 3}); + expect(this.state).toEqual({count: 6}); + expect(snapshot).toBe('3'); + componentDidUpdateCalled = true; + } + render() { + return React.createElement( + 'div', + { + ref: this.setRef, + }, + this.state.count + ); + } + } + + polyfill(Component); + + const container = document.createElement('div'); + ReactDOM.render( + React.createElement(Component, {incrementBy: 2}), + container + ); + + expect(container.textContent).toBe('3'); + expect(componentDidUpdateCalled).toBe(false); + + ReactDOM.render( + React.createElement(Component, {incrementBy: 3}), + container + ); + + expect(container.textContent).toBe('6'); + expect(componentDidUpdateCalled).toBe(true); }); it('should support getDerivedStateFromProps in subclass', () => { @@ -128,6 +212,72 @@ Object.entries(POLYFILLS).forEach(([name, polyfill]) => { expect(container.textContent).toBe('foo,bar'); }); + it('should properly recover from errors thrown by getSnapshotBeforeUpdate()', () => { + let instance; + + class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = {error: null}; + } + componentDidCatch(error) { + this.setState({error}); + } + + render() { + return this.state.error !== null ? null : this.props.children; + } + } + + class Component extends React.Component { + constructor(props) { + super(props); + this.state = {count: 1}; + } + static getDerivedStateFromProps(nextProps, prevState) { + return { + count: prevState.count + nextProps.incrementBy, + }; + } + getSnapshotBeforeUpdate(prevProps) { + throw Error('whoops'); + } + componentDidUpdate(prevProps, prevState, snapshot) {} + render() { + instance = this; + + return null; + } + } + + polyfill(Component); + + const container = document.createElement('div'); + ReactDOM.render( + React.createElement( + ErrorBoundary, + null, + React.createElement(Component, {incrementBy: 2}) + ), + container + ); + + try { + ReactDOM.render( + React.createElement( + ErrorBoundary, + null, + React.createElement(Component, {incrementBy: 3}) + ), + container + ); + } catch (error) {} + + // Verify that props and state get reset after the error + expect(instance.props.incrementBy).toBe(2); + expect(instance.state.count).toBe(3); + }); + it('should error for non-class components', () => { function FunctionalComponent() { return null; @@ -150,7 +300,7 @@ Object.entries(POLYFILLS).forEach(([name, polyfill]) => { polyfill(ComponentWithLifecycles); }); - it('should error if component already has cWM or cWRP lifecycles with static gDSFP', () => { + it('should error if component defines gDSFP but already has cWM or cWRP', () => { class ComponentWithWillMount extends React.Component { componentWillMount() {} static getDerivedStateFromProps() {} @@ -168,10 +318,37 @@ Object.entries(POLYFILLS).forEach(([name, polyfill]) => { } expect(() => polyfill(ComponentWithWillMount)).toThrow( - 'Cannot polyfill if componentWillMount already exists' + 'Cannot polyfill getDerivedStateFromProps() for components that define componentWillMount()' ); expect(() => polyfill(ComponentWithWillReceiveProps)).toThrow( - 'Cannot polyfill if componentWillReceiveProps already exists' + 'Cannot polyfill getDerivedStateFromProps() for components that define componentWillReceiveProps()' + ); + }); + + it('should error if component defines gSBU but already has cWU', () => { + class ComponentWithWillUpdate extends React.Component { + componentWillUpdate() {} + getSnapshotBeforeUpdate() {} + render() { + return null; + } + } + + expect(() => polyfill(ComponentWithWillUpdate)).toThrow( + 'Cannot polyfill getSnapshotBeforeUpdate() for components that define componentWillUpdate()' + ); + }); + + it('should error if component defines gSBU but does not define cDU', () => { + class Component extends React.Component { + getSnapshotBeforeUpdate(prevProps, prevState) {} + render() { + return null; + } + } + + expect(() => polyfill(Component)).toThrow( + 'Cannot polyfill getSnapshotBeforeUpdate() for components that do not define componentDidUpdate() on the prototype' ); }); });