diff --git a/README.md b/README.md index d97d0c8..35e2918 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ The asynchronous component factory. Config goes in, an asynchronous component co - `config` (_Object_) : The configuration object for the async Component. It has the following properties available: - `resolve` (_() => Promise_) : A function that should return a `Promise` that will resolve the Component you wish to be async. - `LoadingComponent` (_Component_, Optional, default: `null`) : A Component that will be displayed until your async Component is resolved. All props will be passed to it. - - `ErrorComponent` (_Component_, Optional, default: `null`) : A Component that will be displayed if any error occurred whilst trying to resolve your component. All props will be passed to it as well as an `error` prop containing the `Error`. + - `ErrorComponent` (_Component_, Optional, default: `null`) : A Component that will be displayed if any error occurred whilst trying to resolve your component. All props will be passed to it as well as an `error` prop containing the `Error` and a `retry` prop that's a function that'll re-attempt calling the `resolve` function. - `name` (_String_, Optional, default: `'AsyncComponent'`) : Use this if you would like to name the created async Component, which helps when firing up the React Dev Tools for example. - `autoResolveES2015Default` (_Boolean_, Optional, default: `true`) : Especially useful if you are resolving ES2015 modules. The resolved module will be checked to see if it has a `.default` and if so then the value attached to `.default` will be used. So easy to forget to do that. 😀 - `env` (_String_, Optional) : Provide either `'node'` or `'browser'` so you can write your own environment detection. Especially useful when using PhantomJS or ElectronJS to prerender the React App. diff --git a/commonjs/asyncComponent.js b/commonjs/asyncComponent.js index 375eced..79ba519 100644 --- a/commonjs/asyncComponent.js +++ b/commonjs/asyncComponent.js @@ -201,6 +201,16 @@ function asyncComponent(config) { return undefined; }); } + }, { + key: 'retryResolvingModule', + value: function retryResolvingModule() { + // clear existing errors + this.registerErrorState(null); + sharedState.error = null; + // clear resolver so it'll be retried + sharedState.resolver = null; + this.resolveModule(); + } }, { key: 'componentWillUnmount', value: function componentWillUnmount() { @@ -224,12 +234,16 @@ function asyncComponent(config) { }, { key: 'render', value: function render() { + var _this5 = this; + var _state = this.state, module = _state.module, error = _state.error; if (error) { - return ErrorComponent ? _react2.default.createElement(ErrorComponent, _extends({}, this.props, { error: error })) : null; + return ErrorComponent ? _react2.default.createElement(ErrorComponent, _extends({}, this.props, { retry: function retry() { + return _this5.retryResolvingModule(); + }, error: error })) : null; } // This is as workaround for React Hot Loader support. When using diff --git a/src/__tests__/__snapshots__/asyncComponent.test.js.snap b/src/__tests__/__snapshots__/asyncComponent.test.js.snap index e3b0e7b..7870522 100644 --- a/src/__tests__/__snapshots__/asyncComponent.test.js.snap +++ b/src/__tests__/__snapshots__/asyncComponent.test.js.snap @@ -1,5 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`asyncComponent in a browser environment when an error occurs resolving a component can retry resolving 1`] = `"
failed to resolve
"`; + +exports[`asyncComponent in a browser environment when an error occurs resolving a component can retry resolving 2`] = `"

I loaded now!

"`; + exports[`asyncComponent in a browser environment when an error occurs resolving a component should render the ErrorComponent 1`] = `"
failed to resolve
"`; exports[`asyncComponent in a server environment when an error occurs resolving a component should not render the ErrorComponent 1`] = `null`; diff --git a/src/__tests__/__snapshots__/integration.test.js.snap b/src/__tests__/__snapshots__/integration.test.js.snap index e054e5d..7704dbc 100644 --- a/src/__tests__/__snapshots__/integration.test.js.snap +++ b/src/__tests__/__snapshots__/integration.test.js.snap @@ -37,6 +37,7 @@ exports[`integration tests browser rendering renders the ErrorComponent 2`] = `
An error occurred @@ -194,6 +195,7 @@ exports[`integration tests render server and client 4`] = `
This always errors diff --git a/src/__tests__/asyncComponent.test.js b/src/__tests__/asyncComponent.test.js index 3c0cc5d..d068b2e 100644 --- a/src/__tests__/asyncComponent.test.js +++ b/src/__tests__/asyncComponent.test.js @@ -24,7 +24,7 @@ describe('asyncComponent', () => { describe('in a browser environment', () => { describe('when an error occurs resolving a component', () => { - it.only('should render the ErrorComponent', async () => { + it('should render the ErrorComponent', async () => { const Bob = asyncComponent({ resolve: () => Promise.reject(new Error('failed to resolve')), ErrorComponent: ({ error }) =>
{error.message}
, @@ -34,12 +34,49 @@ describe('asyncComponent', () => { await new Promise(resolve => setTimeout(resolve, errorResolveDelay)) expect(renderWrapper.html()).toMatchSnapshot() }) + + it('can retry resolving', async () => { + class RetryingError extends React.Component { + componentDidMount() { + setTimeout(() => this.props.retry(), 1) + } + render() { + return
{this.props.error.message}
+ } + } + const asyncProps = { + resolve: jest.fn(() => + Promise.reject(new Error('failed to resolve')), + ), + ErrorComponent: RetryingError, + env: 'browser', + } + const Bob = asyncComponent(asyncProps) + const renderWrapper = mount() + + asyncProps.resolve.mockImplementation(() => + Promise.resolve(() =>

I loaded now!

), + ) + + await new Promise(resolve => + setTimeout(() => { + expect(renderWrapper.html()).toMatchSnapshot() + setTimeout(() => { + expect(renderWrapper.html()).toMatchSnapshot() + resolve() + }, errorResolveDelay) + }, errorResolveDelay), + ) + }) }) }) describe('in a server environment', () => { describe('when an error occurs resolving a component', () => { it('should not render the ErrorComponent', async () => { + const consoleSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => true) const Bob = asyncComponent({ resolve: () => Promise.reject(new Error('failed to resolve')), ErrorComponent: ({ error }) =>
{error.message}
, @@ -48,6 +85,9 @@ describe('asyncComponent', () => { const renderWrapper = mount() await new Promise(resolve => setTimeout(resolve, errorResolveDelay)) expect(renderWrapper.html()).toMatchSnapshot() + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to resolve asyncComponent', + ) }) }) }) diff --git a/src/__tests__/integration.test.js b/src/__tests__/integration.test.js index 2f9ccc2..3003a53 100644 --- a/src/__tests__/integration.test.js +++ b/src/__tests__/integration.test.js @@ -90,6 +90,13 @@ const LoadingComponent = () =>
Loading...
const errorResolveDelay = 20 describe('integration tests', () => { + let consoleSpy; + + beforeEach(() => { + consoleSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => true) + }) it('render server and client', () => { // we have to delete the window to emulate a server only environment let windowTemp = global.window @@ -233,6 +240,7 @@ describe('integration tests', () => { ) + expect(consoleSpy).toHaveBeenCalledWith('Failed to resolve asyncComponent') const bootstrappedApp = await asyncBootstrapper(app) await new Promise(resolve => setTimeout(resolve, errorResolveDelay)) expect(renderToStaticMarkup(bootstrappedApp)).toMatchSnapshot() diff --git a/src/asyncComponent.js b/src/asyncComponent.js index 75e0bd2..ae8739f 100644 --- a/src/asyncComponent.js +++ b/src/asyncComponent.js @@ -168,6 +168,15 @@ function asyncComponent(config) { }) } + retryResolvingModule() { + // clear existing errors + this.registerErrorState(null) + sharedState.error = null + // clear resolver so it'll be retried + sharedState.resolver = null + this.resolveModule() + } + componentWillUnmount() { this.unmounted = true } @@ -188,7 +197,11 @@ function asyncComponent(config) { const { module, error } = this.state if (error) { return ErrorComponent ? ( - + this.retryResolvingModule()} + error={error} + /> ) : null } diff --git a/umd/react-async-component.js b/umd/react-async-component.js index 761fba7..db4b6df 100644 --- a/umd/react-async-component.js +++ b/umd/react-async-component.js @@ -462,6 +462,16 @@ function asyncComponent(config) { return undefined; }); } + }, { + key: 'retryResolvingModule', + value: function retryResolvingModule() { + // clear existing errors + this.registerErrorState(null); + sharedState.error = null; + // clear resolver so it'll be retried + sharedState.resolver = null; + this.resolveModule(); + } }, { key: 'componentWillUnmount', value: function componentWillUnmount() { @@ -485,12 +495,16 @@ function asyncComponent(config) { }, { key: 'render', value: function render() { + var _this5 = this; + var _state = this.state, module = _state.module, error = _state.error; if (error) { - return ErrorComponent ? _react2.default.createElement(ErrorComponent, _extends({}, this.props, { error: error })) : null; + return ErrorComponent ? _react2.default.createElement(ErrorComponent, _extends({}, this.props, { retry: function retry() { + return _this5.retryResolvingModule(); + }, error: error })) : null; } // This is as workaround for React Hot Loader support. When using diff --git a/umd/react-async-component.min.js b/umd/react-async-component.min.js index 2382552..635a0ed 100644 --- a/umd/react-async-component.min.js +++ b/umd/react-async-component.min.js @@ -1 +1 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("react"),require("prop-types")):"function"==typeof define&&define.amd?define(["react","prop-types"],t):"object"==typeof exports?exports.ReactAsyncComponent=t(require("react"),require("prop-types")):e.ReactAsyncComponent=t(e.React,e.PropTypes)}(this,function(e,t){return function(e){function t(o){if(n[o])return n[o].exports;var r=n[o]={i:o,l:!1,exports:{}};return e[o].call(r.exports,r,r.exports,t),r.l=!0,r.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:o})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=3)}([function(t,n){t.exports=e},function(e,n){e.exports=t},function(e,t,n){"use strict";function o(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function r(){var e=0,t={};return{getNextId:function(){return e+=1},resolved:function(e){t[e]=!0},getState:function(){return{resolved:Object.keys(t).reduce(function(e,t){return Object.assign(e,o({},t,!0))},{})}}}}Object.defineProperty(t,"__esModule",{value:!0}),t.default=r},function(e,t,n){"use strict";function o(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0}),t.asyncComponent=t.createAsyncContext=t.AsyncComponentProvider=void 0;var r=n(4),u=o(r),s=n(2),i=o(s),l=n(5),a=o(l);t.AsyncComponentProvider=u.default,t.createAsyncContext=i.default,t.asyncComponent=a.default},function(e,t,n){"use strict";function o(e){return e&&e.__esModule?e:{default:e}}function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function u(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}function s(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}Object.defineProperty(t,"__esModule",{value:!0});var i=function(){function e(e,t){for(var n=0;n-1?e.env:"undefined"==typeof window?"node":"browser",C={id:null,module:null,error:null,resolver:null},x=function(e){return i&&null!=e&&("function"==typeof e||"object"===(void 0===e?"undefined":c(e)))&&e.default?e.default:e},g=function(){if(null==C.resolver)try{var e=n();C.resolver=Promise.resolve(e)}catch(e){C.resolver=Promise.reject(e)}return C.resolver},w=function(e){function t(e,n){r(this,t);var o=u(this,(t.__proto__||Object.getPrototypeOf(t)).call(this,e,n));return null==o.context.asyncComponents||C.id||(C.id=o.context.asyncComponents.getNextId()),o}return s(t,e),a(t,[{key:"asyncBootstrap",value:function(){var e=this,t=this.context,n=t.asyncComponents,o=t.asyncComponentsAncestor,r=n.shouldRehydrate,u=function(){return e.resolveModule().then(function(e){return void 0!==e})};if("browser"===b)return!!r(C.id)&&u();var s=null!=o&&o.isBoundary;return"defer"!==p&&!s&&u()}},{key:"getChildContext",value:function(){return null==this.context.asyncComponents?{asyncComponentsAncestor:null}:{asyncComponentsAncestor:{isBoundary:"boundary"===p}}}},{key:"componentWillMount",value:function(){this.setState({module:C.module}),C.error&&this.registerErrorState(C.error)}},{key:"componentDidMount",value:function(){this.shouldResolve()&&this.resolveModule()}},{key:"shouldResolve",value:function(){return null==C.module&&null==C.error&&!this.resolving&&"undefined"!=typeof window}},{key:"resolveModule",value:function(){var e=this;return this.resolving=!0,g().then(function(t){if(!e.unmounted)return null!=e.context.asyncComponents&&e.context.asyncComponents.resolved(C.id),C.module=t,"browser"===b&&e.setState({module:t}),e.resolving=!1,t}).catch(function(t){e.unmounted||(("node"===b||"browser"===b&&!m)&&(console.warn("Failed to resolve asyncComponent"),console.warn(t)),C.error=t,e.registerErrorState(t),e.resolving=!1)})}},{key:"componentWillUnmount",value:function(){this.unmounted=!0}},{key:"registerErrorState",value:function(e){var t=this;"browser"===b&&setTimeout(function(){t.unmounted||t.setState({error:e})},16)}},{key:"render",value:function(){var e=this.state,t=e.module,n=e.error;if(n)return m?d.default.createElement(m,l({},this.props,{error:n})):null;this.shouldResolve()&&this.resolveModule();var o=x(t);return o?d.default.createElement(o,this.props):h?d.default.createElement(h,this.props):null}}]),t}(d.default.Component);return w.displayName=t||"AsyncComponent",w.contextTypes={asyncComponentsAncestor:y.default.shape({isBoundary:y.default.bool}),asyncComponents:y.default.shape({getNextId:y.default.func.isRequired,resolved:y.default.func.isRequired,shouldRehydrate:y.default.func.isRequired})},w.childContextTypes={asyncComponentsAncestor:y.default.shape({isBoundary:y.default.bool})},w}Object.defineProperty(t,"__esModule",{value:!0});var l=Object.assign||function(e){for(var t=1;t-1?e.env:"undefined"==typeof window?"node":"browser",C={id:null,module:null,error:null,resolver:null},x=function(e){return i&&null!=e&&("function"==typeof e||"object"===(void 0===e?"undefined":c(e)))&&e.default?e.default:e},g=function(){if(null==C.resolver)try{var e=n();C.resolver=Promise.resolve(e)}catch(e){C.resolver=Promise.reject(e)}return C.resolver},w=function(e){function t(e,n){r(this,t);var o=u(this,(t.__proto__||Object.getPrototypeOf(t)).call(this,e,n));return null==o.context.asyncComponents||C.id||(C.id=o.context.asyncComponents.getNextId()),o}return s(t,e),a(t,[{key:"asyncBootstrap",value:function(){var e=this,t=this.context,n=t.asyncComponents,o=t.asyncComponentsAncestor,r=n.shouldRehydrate,u=function(){return e.resolveModule().then(function(e){return void 0!==e})};if("browser"===b)return!!r(C.id)&&u();var s=null!=o&&o.isBoundary;return"defer"!==p&&!s&&u()}},{key:"getChildContext",value:function(){return null==this.context.asyncComponents?{asyncComponentsAncestor:null}:{asyncComponentsAncestor:{isBoundary:"boundary"===p}}}},{key:"componentWillMount",value:function(){this.setState({module:C.module}),C.error&&this.registerErrorState(C.error)}},{key:"componentDidMount",value:function(){this.shouldResolve()&&this.resolveModule()}},{key:"shouldResolve",value:function(){return null==C.module&&null==C.error&&!this.resolving&&"undefined"!=typeof window}},{key:"resolveModule",value:function(){var e=this;return this.resolving=!0,g().then(function(t){if(!e.unmounted)return null!=e.context.asyncComponents&&e.context.asyncComponents.resolved(C.id),C.module=t,"browser"===b&&e.setState({module:t}),e.resolving=!1,t}).catch(function(t){e.unmounted||(("node"===b||"browser"===b&&!m)&&(console.warn("Failed to resolve asyncComponent"),console.warn(t)),C.error=t,e.registerErrorState(t),e.resolving=!1)})}},{key:"retryResolvingModule",value:function(){this.registerErrorState(null),C.error=null,C.resolver=null,this.resolveModule()}},{key:"componentWillUnmount",value:function(){this.unmounted=!0}},{key:"registerErrorState",value:function(e){var t=this;"browser"===b&&setTimeout(function(){t.unmounted||t.setState({error:e})},16)}},{key:"render",value:function(){var e=this,t=this.state,n=t.module,o=t.error;if(o)return m?d.default.createElement(m,l({},this.props,{retry:function(){return e.retryResolvingModule()},error:o})):null;this.shouldResolve()&&this.resolveModule();var r=x(n);return r?d.default.createElement(r,this.props):h?d.default.createElement(h,this.props):null}}]),t}(d.default.Component);return w.displayName=t||"AsyncComponent",w.contextTypes={asyncComponentsAncestor:y.default.shape({isBoundary:y.default.bool}),asyncComponents:y.default.shape({getNextId:y.default.func.isRequired,resolved:y.default.func.isRequired,shouldRehydrate:y.default.func.isRequired})},w.childContextTypes={asyncComponentsAncestor:y.default.shape({isBoundary:y.default.bool})},w}Object.defineProperty(t,"__esModule",{value:!0});var l=Object.assign||function(e){for(var t=1;t