diff --git a/README.md b/README.md index 7201ae1..ff8ec3f 100644 --- a/README.md +++ b/README.md @@ -305,12 +305,32 @@ Loadable({ ### Customizing rendering -`render(state, props)` can access `state` and `props` to determine the current state of the loader. - By default `Loadable` will render the `default` export of the returned module. If you want to customize this behavior you can use the [`render` option](#optsrender). +#### With a `loading` component + +When a `loading` prop is defined on Loadable, the `render` method is only invoked +after the loader import has completed loading. + +```js +Loadable({ + loader: () => import('./my-component'), + loading: Loading, + render(loaded, props) { + let Component = loaded.namedExport; + return ; + } +}); +``` + +#### Without a `loading` component + +When no `loading` prop is defined on Loadable, `render(state, props)` can access +`state` and `props` to determine the current state of the loader. `render` is invoked +for every render of the component, regardless of the current loader state. + ```js Loadable({ loader: () => import('./my-component'), @@ -335,13 +355,41 @@ But writing it out can be a bit annoying. To make it easier to load multiple resources in parallel, you can use [`Loadable.Map`](#loadablemap). +When using `Loadable.Map` the [`render()` method](#optsrender) is required. + +#### With a `loading` component + +```js +Loadable.Map({ + loader: { + Bar: () => import('./Bar'), + i18n: () => fetch('./i18n/bar.json').then(res => res.json()), + }, + loading: Loading, + render(loaded, props) { + let Bar = loaded.Bar.default; + let i18n = loaded.i18n; + return ; + }, +}); +``` +The `render(loaded, props)` method is passed `loaded` and `props` arguments, and invoked after +the loader has completed loading. + +#### Without a `loading` component + ```js Loadable.Map({ loader: { Bar: () => import('./Bar'), i18n: () => fetch('./i18n/bar.json').then(res => res.json()), }, - render({ loaded }, props) { + render(state, props) { + const { isLoading, loaded } = state; + if (isLoading) { + return
Loading...
; + } + let Bar = loaded.Bar.default; let i18n = loaded.i18n; return ; @@ -349,9 +397,8 @@ Loadable.Map({ }); ``` -When using `Loadable.Map` the [`render()` method](#optsrender) is required. It -will be passed a `loaded` param which will be an object matching the shape of -your `loader`. +The `render(state, props)` method is passed `state` and `props` arguments, and is invoked +for every render of the Loadable regardless of the internal loadable state. ### Preloading @@ -676,13 +723,19 @@ A higher-order component that allows you to load multiple resources in parallel. Loadable.Map's [`opts.loader`](#optsloader) accepts an object of functions, and needs a [`opts.render`](#optsrender) method. +#### With a `loading` component + +When a `loading` prop is defined on Loadable, the `render` method is only invoked +after the loader import has completed loading. + ```js Loadable.Map({ loader: { Bar: () => import('./Bar'), i18n: () => fetch('./i18n/bar.json').then(res => res.json()), }, - render({ loaded }, props) { + loading: Loading, + render(loaded, props) { let Bar = loaded.Bar.default; let i18n = loaded.i18n; return ; @@ -690,8 +743,33 @@ Loadable.Map({ }); ``` -When using `Loadable.Map` the `render()` method's `loaded` param will be an -object with the same shape as your `loader`. +When using `Loadable.Map` the `render()` method's `loaded` param is an object and +it will have a `loader` prop with the same shape as your `loader`. + +#### Without a `loading` component + +```js +Loadable.Map({ + loader: { + Bar: () => import('./Bar'), + i18n: () => fetch('./i18n/bar.json').then(res => res.json()), + }, + render(state, props) { + const { isLoading, loaded } = state; + if (isLoading) { + return
Loading...
; + } + + let Bar = loaded.Bar.default; + let i18n = loaded.i18n; + return ; + }, +}); +``` + +When using `Loadable.Map` the `render(state, props)` method is passed `state` and `props` arguments, and is invoked +for every render of the Loadable regardless of the internal loadable state. + ### `Loadable` and `Loadable.Map` Options @@ -725,17 +803,15 @@ When using with `Loadable.Map` you'll also need to pass a A [`LoadingComponent`](#loadingcomponent) that renders while a module is loading or when it errors. -```js -Loadable({ - loading: LoadingComponent, -}); -``` - -This option is required, if you don't want to render anything, return `null`. +The `loading` prop changes the arguments received by the `render` method, this is to maintain backwards compatibility. + * When the `loading` prop **is** defined, the first argument received by the `render(loaded, props)` method + is the `loaded` that is the resolved value of [`opts.loader`](#optsloader) + * When the `loading` props **is not** defined, the first argument received by `render(state, props)` method + is the loadable state. You can access the loaded component(s) using `state.loaded`. ```js Loadable({ - loading: () => null, + loading: LoadingComponent, }); ``` @@ -769,15 +845,81 @@ Loadable({ #### `opts.render` -A function to customize the rendering of loaded modules. +A react element or function to customize the rendering of loaded modules. + +##### React element with a `loading` component -Receives `loaded` which is the resolved value of [`opts.loader`](#optsloader) +Receives a `loaded` prop that is the resolved value of [`opts.loader`](#optsloader) and `props` which are the props passed to the [`LoadableComponent`](#loadablecomponent). ```js +function CodeSplitRenderer(props) { + const { codeSplit, ...componentProps } = props; + + // when used with a 'loading' component, the loaded state is assigned directly to the `codeSplit` prop + let Component = codeSplit.default; + return ; +} + Loadable({ - render({ loaded }, props) { + loading: Loading, + render: +}); +``` + +##### React element without a `loading` component + +Receives `state`, with a `loader` prop that is the resolved value of [`opts.loader`](#optsloader) +and `props` which are the props passed to the +[`LoadableComponent`](#loadablecomponent). + +```js +function CodeSplitRenderer(props) { + const { codeSplit, ...componentProps } = props; + + // when used without a 'loading' component, the loadable state is assigned to the `codeSplit` prop + const { isLoading, loaded, pastDelay, timedOut, error } = codeSplit; + + let Component = loaded.default; + return ; +} + +Loadable({ + render: +}); +``` + +##### Function with a `loading` component + +Receives a `loaded` prop that is the resolved value of [`opts.loader`](#optsloader) +and `props` which are the props passed to the +[`LoadableComponent`](#loadablecomponent). + +```js +Loadable({ + loading: Loading, + render(loaded, props) { + let Component = loaded.default; + return ; + } +}); +``` + +##### Function without a `loading` component + +Receives `state`, with a `loader` prop that is the resolved value of [`opts.loader`](#optsloader) +and `props` which are the props passed to the +[`LoadableComponent`](#loadablecomponent). + +```js +Loadable({ + render(state, props) { + const { isLoading, loaded, pastDelay, timedOut, error } = state; + if (isLoading) { + return
Loading...
; + } + let Component = loaded.default; return ; } diff --git a/__tests__/__snapshots__/test.js.snap b/__tests__/__snapshots__/test.js.snap index 5bb3c06..d2f21ac 100644 --- a/__tests__/__snapshots__/test.js.snap +++ b/__tests__/__snapshots__/test.js.snap @@ -28,6 +28,33 @@ exports[`delay and timeout 4`] = ` `; +exports[`loadable map element success without loading prop 1`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null,"loaded":{}} +
+`; + +exports[`loadable map element success without loading prop 2`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null,"loaded":{}} +
+`; + +exports[`loadable map element success without loading prop 3`] = ` +
+
+ MyComponent + {"prop":"baz"} +
+
+ MyComponent + {"prop":"baz"} +
+
+`; + exports[`loadable map error 1`] = `
MyLoadingComponent @@ -49,6 +76,27 @@ exports[`loadable map error 3`] = `
`; +exports[`loadable map error without loading prop 1`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null,"loaded":{}} +
+`; + +exports[`loadable map error without loading prop 2`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null,"loaded":{}} +
+`; + +exports[`loadable map error without loading prop 3`] = ` +
+ MyLoadingComponent + {"isLoading":false,"pastDelay":true,"timedOut":false,"error":{},"loaded":{"a":{}}} +
+`; + exports[`loadable map success 1`] = `
MyLoadingComponent @@ -76,6 +124,33 @@ exports[`loadable map success 3`] = `
`; +exports[`loadable map success without loading prop 1`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null,"loaded":{}} +
+`; + +exports[`loadable map success without loading prop 2`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null,"loaded":{}} +
+`; + +exports[`loadable map success without loading prop 3`] = ` +
+
+ MyComponent + {"prop":"baz"} +
+
+ MyComponent + {"prop":"baz"} +
+
+`; + exports[`loading error 1`] = `
MyLoadingComponent @@ -202,21 +277,63 @@ exports[`render 3`] = `
`; -exports[`render loading inline 1`] = ` +exports[`render element 1`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} +
+`; + +exports[`render element 2`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null} +
+`; + +exports[`render element 3`] = ` +
+ MyComponent + {"prop":"baz"} +
+`; + +exports[`render element without loading prop 1`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null,"loaded":null} +
+`; + +exports[`render element without loading prop 2`] = ` +
+ MyLoadingComponent + {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null,"loaded":null} +
+`; + +exports[`render element without loading prop 3`] = ` +
+ MyComponent + {"prop":"baz"} +
+`; + +exports[`render without loading prop 1`] = `
MyLoadingComponent {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null,"loaded":null}
`; -exports[`render loading inline 2`] = ` +exports[`render without loading prop 2`] = `
MyLoadingComponent {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null,"loaded":null}
`; -exports[`render loading inline 3`] = ` +exports[`render without loading prop 3`] = `
MyComponent {"prop":"baz"} diff --git a/__tests__/test.js b/__tests__/test.js index e19b260..c1ccd42 100644 --- a/__tests__/test.js +++ b/__tests__/test.js @@ -30,6 +30,39 @@ function MyComponent(props) { return
MyComponent {JSON.stringify(props)}
; } +function CodeSplitRenderer({ codeSplit, ...props }) { + // Enable component to render with or without a 'loading' component configured + const { isLoading, loaded } = typeof codeSplit.isLoading === 'boolean' ? codeSplit : { loaded: codeSplit }; + if (isLoading) { + return ; + } + + return ; +} + +function CodeSplitMapRenderer({ codeSplit, ...props }) { + const { loaded } = codeSplit; + return whenLoaded(codeSplit, () => ( +
+ + +
+ )); +} + +function whenLoaded(state, loadedComponent) { + const { isLoading, error, loaded } = state; + if (isLoading || error) { + return
MyLoadingComponent {JSON.stringify(state)}
; + } + + if (loaded) { + return loadedComponent(); + } + + return null; +} + afterEach(async () => { try { await Loadable.preloadAll(); @@ -138,7 +171,7 @@ test('render', async () => { let LoadableMyComponent = Loadable({ loader: createLoader(400, () => ({ MyComponent })), loading: MyLoadingComponent, - render({ loaded }, props) { + render(loaded, props) { return ; } }); @@ -150,7 +183,21 @@ test('render', async () => { expect(component.toJSON()).toMatchSnapshot(); // success }); -test('render loading inline', async () => { +test('render element', async () => { + let LoadableMyComponent = Loadable({ + loader: createLoader(400, () => ({ MyComponent })), + loading: MyLoadingComponent, + render: + }); + let component = renderer.create(); + expect(component.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // loading + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // success +}); + +test('render without loading prop', async () => { let LoadableMyComponent = Loadable({ loader: createLoader(400, () => ({ MyComponent })), render(state, props) { @@ -169,6 +216,19 @@ test('render loading inline', async () => { expect(component.toJSON()).toMatchSnapshot(); // success }); +test('render element without loading prop', async () => { + let LoadableMyComponent = Loadable({ + loader: createLoader(400, () => ({ MyComponent })), + render: + }); + let component = renderer.create(); + expect(component.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // loading + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // success +}); + test('loadable map success', async () => { let LoadableMyComponent = Loadable.Map({ loader: { @@ -176,7 +236,7 @@ test('loadable map success', async () => { b: createLoader(400, () => ({ MyComponent })), }, loading: MyLoadingComponent, - render({ loaded }, props) { + render(loaded, props) { return (
@@ -194,6 +254,48 @@ test('loadable map success', async () => { expect(component.toJSON()).toMatchSnapshot(); // success }); +test('loadable map success without loading prop', async () => { + let LoadableMyComponent = Loadable.Map({ + loader: { + a: createLoader(200, () => ({ MyComponent })), + b: createLoader(400, () => ({ MyComponent })), + }, + render(state, props) { + const { loaded } = state; + return whenLoaded(state, () => ( +
+ + +
+ )); + } + }); + + let component = renderer.create(); + expect(component.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // loading + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // success +}); + +test('loadable map element success without loading prop', async () => { + let LoadableMyComponent = Loadable.Map({ + loader: { + a: createLoader(200, () => ({ MyComponent })), + b: createLoader(400, () => ({ MyComponent })), + }, + render: + }); + + let component = renderer.create(); + expect(component.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // loading + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // success +}); + test('loadable map error', async () => { let LoadableMyComponent = Loadable.Map({ loader: { @@ -201,7 +303,7 @@ test('loadable map error', async () => { b: createLoader(400, null, new Error('test error')), }, loading: MyLoadingComponent, - render({loaded}, props) { + render(loaded, props) { return (
@@ -216,7 +318,32 @@ test('loadable map error', async () => { await waitFor(200); expect(component.toJSON()).toMatchSnapshot(); // loading await waitFor(200); - expect(component.toJSON()).toMatchSnapshot(); // success + expect(component.toJSON()).toMatchSnapshot(); // error +}); + +test('loadable map error without loading prop', async () => { + let LoadableMyComponent = Loadable.Map({ + loader: { + a: createLoader(200, () => ({ MyComponent })), + b: createLoader(400, null, new Error('test error')), + }, + render(state, props) { + const { loaded } = state; + return whenLoaded(state, () => ( +
+ + +
+ )); + } + }); + + let component = renderer.create(); + expect(component.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // loading + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // error }); describe('preloadReady', () => { diff --git a/example/components/PreLoadButton.js b/example/components/PreLoadButton.js index 42c3f8b..7e744e2 100644 --- a/example/components/PreLoadButton.js +++ b/example/components/PreLoadButton.js @@ -3,7 +3,7 @@ import Loadable from 'react-loadable'; import Loading from "./Loading"; const LoadableContent = Loadable({ - loader: () => import('./PreLoadedContent'), + loader: () => import(/* webpackChunkName: "PreLoadedContent" */ './PreLoadedContent'), loading: Loading, }); diff --git a/src/index.js b/src/index.js index 696b180..67dc854 100644 --- a/src/index.js +++ b/src/index.js @@ -87,7 +87,15 @@ function resolve(obj) { return obj && obj.__esModule ? obj.default : obj; } -function defaultRender(state, props) { +function backwardsCompatibleRender(loaded, props) { + if (loaded) { + return React.createElement(resolve(loaded), props); + } + + return null; +} + +function stateRender(state, props) { const { loaded } = state; if (loaded) { return React.createElement(resolve(loaded), props); @@ -102,7 +110,7 @@ function createLoadableComponent(loadFn, options) { loading: null, delay: 200, timeout: null, - render: defaultRender, + render: options.loading ? backwardsCompatibleRender : stateRender, webpack: null, modules: [], }, options); @@ -227,14 +235,20 @@ function createLoadableComponent(loadFn, options) { }; if (opts.loading) { - // maintain backwards compatibility - support 'loading' option - if ((renderState.isLoading || renderState.error) && opts.loading) { - return React.createElement(opts.loading, renderState) + // maintain full backwards compatibility - support 'loading' option + if (renderState.isLoading || renderState.error) { + return React.createElement(opts.loading, renderState); } + + return React.isValidElement(opts.render) ? + React.cloneElement(opts.render, Object.assign({}, this.props, { codeSplit: this.state.loaded })) : + opts.render(this.state.loaded, this.props); } renderState.loaded = this.state.loaded; - return opts.render(renderState, this.props); + return React.isValidElement(opts.render) ? + React.cloneElement(opts.render, Object.assign({}, this.props, { codeSplit: renderState })) : + opts.render(renderState, this.props); } }; } @@ -244,8 +258,8 @@ function Loadable(opts) { } function LoadableMap(opts) { - if (typeof opts.render !== 'function') { - throw new Error('LoadableMap requires a `render(state, props)` function'); + if (!(React.isValidElement(opts.render) || typeof opts.render === 'function')) { + throw new Error('LoadableMap requires a `render` react element or function'); } return createLoadableComponent(loadMap, opts);