diff --git a/.babelrc b/.babelrc index 78feca6..d33fd50 100644 --- a/.babelrc +++ b/.babelrc @@ -7,5 +7,6 @@ ], "plugins": [ "transform-class-properties", + "transform-object-rest-spread" ], } diff --git a/README.md b/README.md index f1d1003..a39f889 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,167 @@ -![React Loadable](http://thejameskyle.com/img/react-loadable-header.png) +# react-chunk + +_Code splitting with minimal boiler plate_ > A higher order component for loading components with dynamic imports. + +_This is a fork of [react-loadable](https://github.com/jamiebuilds/react-loadable), differences and new features include:_ + * _A modified API to support new features_ + * _Improved re-use of import components_ + * _Improved support for route code splitting_ + * _Preloading all chunks required to render an entire route_ + * _Option to _hoist_ static methods of imported components_ + * _Option to enable retry support with backoff_ + * _Manually invoking a retry after timeout or error_ + * _Support for [react-router-config](https://github.com/ReactTraining/react-router/tree/master/packages/react-router-config) code splitting_ + +> This enables **both** _component_ and _route_ code splitting + ## Install ```sh -yarn add react-loadable +npm install --save react-chunk +``` + +```sh +yarn add react-chunk ``` ## Example +For more detailed examples, [take a look at the examples](https://github.com/adam-26/react-chunk/tree/master/example) + +### Single Import + ```js -import Loadable from 'react-loadable'; -import Loading from './my-loading-component'; +import { chunk } from 'react-chunk'; -const LoadableComponent = Loadable({ - loader: () => import('./my-component'), - loading: Loading, -}); +// It can be this easy! +const MyComponentChunk = chunk(() => import('./my-component'))(); export default class App extends React.Component { render() { - return ; + return ; } } ``` -## Happy Customers: - -- ["I'm obsessed with this right now: CRA with React Router v4 and react-loadable. Free code splitting, this is so easy."](https://twitter.com/matzatorski/status/872059865350406144) -- ["Webpack 2 upgrade & react-loadable; initial load from 1.1mb to 529kb in under 2 hours. Immense."](https://twitter.com/jwbradley87/status/847191118269833216) -- ["Oh hey - using loadable component I knocked 13K off my initial load. Easy win!"](https://twitter.com/AdamRackis/status/846593080992153600) -- ["Had a look and its awesome. shaved like 50kb off our main bundle."](https://github.com/quran/quran.com-frontend/pull/701#issuecomment-287908551) -- ["I've got that server-side rendering + code splitting + PWA ServiceWorker caching setup done šŸ˜Ž (thanks to react-loadable). Now our frontend is super fast."](https://twitter.com/mxstbr/status/922375575217627136) -- ["Using react-loadable went from 221.28 KB ā†’ 115.76 KB @ main bundle. Fucking awesome and very simple API."](https://twitter.com/evgenyrodionov/status/958821614644269057) - -## Users - -- [Atlassian](https://www.atlassian.com/) -- [Cloudflare](https://www.cloudflare.com) -- [Curio](https://www.curio.org) -- [Flyhomes](https://flyhomes.com) -- [MediaTek MCS-Lite](https://github.com/MCS-Lite) -- [Snipit](https://snipit.io) -- [Spectrum.chat](https://spectrum.chat) -- [Talentpair](https://talentpair.com) -- [Tinder](https://tinder.com/) -- [Unsplash](https://unsplash.com/) - -> _If your company or project is using React Loadable, please open a PR and add -> yourself to this list (in alphabetical order please)_ - -## Also See: - -- [`react-loadable-visibility`](https://github.com/stratiformltd/react-loadable-visibility) - Building on top of and keeping the same API as `react-loadable`, this library enables you to load content that is visible on the screen. - -

-
-
- GUIDE -
-
- Guide -

- -So you've got your React app, you're bundling it with Webpack, and things are -going smooth. But then one day you notice your app's bundle is getting so big -that it's slowing things down. - -It's time to start code-splitting your app! - -![A single giant bundle vs multiple smaller bundles](http://thejameskyle.com/img/react-loadable-split-bundles.png) +### Multiple Imports +```js +import { chunks } from 'react-chunk'; + +// A component for rendering mutilple imports +function MutilImportRenderer(props) { + const { + chunk: { + isLoaded, + imported: { + MyComponent, + MyOtherComponent + } + }, + ...restProps + }) = props; + + if (isLoaded) { + return ( +
+ + +
+ ); + } -Code-splitting is the process of taking one large bundle containing your entire -app, and splitting them up into multiple smaller bundles which contain separate -parts of your app. + return
Loading...
; +} -This might seem difficult to do, but tools like Webpack have this built in, and -React Loadable is designed to make it super simple. +const MyComponentsChunk = chunks({ + MyComponent: () => import('./my-component'), + MyOtherComponent: () => import('./my-other-component'), +})(MutilImportRenderer); -### Route-based splitting vs. Component-based splitting +export default class App extends React.Component { + render() { + return ; + } +} +``` -A common piece of advice you will see is to break your app into separate routes -and load each one asynchronously. This seems to work well enough for many appsā€“ -as a user, clicking a link and waiting for a page to load is a familiar -experience on the web. +## Environment Configuration -But we can do better than that. +It's _recommended_ you configure your development environment with the following plugins. -Using most routing tools for React, a route is simply a component. There's -nothing particularly special about them (Sorry Ryan and Michaelā€“ you're what's -special). So what if we optimized for splitting around components instead of -routes? What would that get us? +### Client -![Route vs. component centric code splitting](http://thejameskyle.com/img/react-loadable-component-splitting.png) +Configure your client build. -As it turns out: Quite a lot. There are many more places than just routes where -you can pretty easily split apart your app. Modals, tabs, and many more UI -components hide content until the user has done something to reveal it. +#### Babel -> **Example:** Maybe your app has a map buried inside of a tab component. Why -> would you load a massive mapping library for the parent route every time when -> the user may never go to that tab? +Add these plugins to your babel configuration. -Not to mention all the places where you can defer loading content until higher -priority content is finished loading. That component at the bottom of your page -which loads a bunch of libraries: Why should that be loaded at the same time as -the content at the top? +```sh +npm install --save-dev babel-plugin-syntax-dynamic-import +``` -And because routes are just components, we can still easily code-split at the -route level. +The **order** of plugins is important. -Introducing new code-splitting points in your app should be so easy that you -don't think twice about it. It should be a matter of changing a few lines of -code and everything else should be automated. +`.babelrc` +```json +{ + "presets": {...}, + "plugins": [ + "react-chunk/babel", + "syntax-dynamic-import" + ] +} -### Introducing React Loadable +``` -React Loadable is a small library that makes component-centric code splitting -incredibly easy in React. +#### Webpack -`Loadable` is a higher-order component (a function that creates a component) -which lets you dynamically load any module before rendering it into your app. +The webpack plugin will write the chunk module data to a file required for server-side rendering. -Let's imagine two components, one that imports and renders another. +Add the plugin to your _client_ webpack plugins ```js -import Bar from './components/Bar'; +import { ReactChunkPlugin } from 'react-chunk/webpack'; + +plugins: [ + new ReactChunkPlugin({ + filename: path.join(__dirname, 'dist', 'react-chunk.json') + }) +] -class Foo extends React.Component { - render() { - return ; - } -} ``` -Right now we're depending on `Bar` being imported synchronously via `import`, -but we don't need it until we go to render it. So why don't we just defer that? +### Server -Using a **dynamic import** ([a tc39 proposal currently at Stage 3](https://github.com/tc39/proposal-dynamic-import)) -we can modify our component to load `Bar` asynchronously. +If your application performs SSR, configure your server build. -```js -class MyComponent extends React.Component { - state = { - Bar: null - }; +#### Babel - componentWillMount() { - import('./components/Bar').then(Bar => { - this.setState({ Bar }); - }); - } +Add these plugins to your babel configuration. - render() { - let {Bar} = this.state; - if (!Bar) { - return
Loading...
; - } else { - return ; - }; - } -} +```sh +npm install --save-dev babel-plugin-dynamic-import-node ``` -But that's a whole bunch of work, and it doesn't even handle a bunch of cases. -What about when `import()` fails? What about server-side rendering? +The **order** of plugins is important. -Instead you can use `Loadable` to abstract away the problem. +`.babelrc` +```json +{ + "presets": {...}, + "plugins": [ + "react-chunk/babel", + "dynamic-import-node" + ] +} -```js -import Loadable from 'react-loadable'; +``` -const LoadableBar = Loadable({ - loader: () => import('./components/Bar'), - loading() { - return
Loading...
- } -}); +## Introduction -class MyComponent extends React.Component { - render() { - return ; - } -} -``` ### Automatic code-splitting on `import()` @@ -193,77 +170,100 @@ When you use `import()` with Webpack 2+, it will you with no additional configuration. This means that you can easily experiment with new code splitting points just -by switching to `import()` and using React Loadable. Figure out what performs +by switching to `import()` and using React Chunk. Figure out what performs best for your app. -### Creating a great "Loading..." Component -Rendering a static "Loading..." doesn't communicate enough to the user. You -also need to think about error states, timeouts, and making it a nice -experience. +### Naming webpack chunks + +Its often useful to assign _names_ to webpack chunks. This can be achieved easily using inline code comments. ```js -function Loading() { - return
Loading...
; -} +import { chunk, chunks } from 'react-chunk'; + +const AppChunk = + chunk(() => import(/* webpackChunkName: "App" */ './app'))(); + +const TimeChunk = + chunks({ + Calendar: () => import(/* webpackChunkName: "calendar" */ './calendar'), + Clock: () => import(/* webpackChunkName: "clock" */ './clock'), + })(TimeRenderer); -Loadable({ - loader: () => import('./WillFailToLoad'), // oh no! - loading: Loading, -}); ``` -To make this all nice, your [loading component](#loadingcomponent) receives a -couple different props. +### Rendering using chunk props -#### Loading error states +Rendering a static "Loading..." doesn't communicate enough to the user. You +also need to think about error states, timeouts, retries, and making it a nice user experience. -When your [`loader`](optsloader) fails, your [loading component](#loadingcomponent) -will receive an [`error`](propserror) prop which will be `true` (otherwise it -will be `false`). +As a developer, you can easiliy re-use import rendering logic when importing a single component. Renderering components for multiple components don't require much more effort. ```js -function Loading(props) { - if (props.error) { - return
Error!
; - } else { +function ChunkRenderer(props) { + const { + chunk: { + isLoading, + hasLoaded, + pastDelay, + timedOut, + error, + retry, + loaded, + Imported + }, + ...restProps + } = prop; + + if (hasLoaded) { + return ; + } + + if (error) { + return
An error occured
; + } + + if (timedOut) { + return ( +
+ This is taking a while.. + retry()}>retry? +
+ ); + } + + if (isLoading && pastDelay) { return
Loading...
; } + + return null; } + +chunk(() => import('./someComponent'))(ChunkRenderer); ``` +To make this all nice, your [chunk component](#loadingcomponent) receives a +couple different props. + + #### Avoiding _Flash Of Loading Component_ -Sometimes components load really quickly (<200ms) and the loading screen only +Sometimes components load really quickly (< 200ms) and the loading screen only quickly flashes on the screen. A number of user studies have proven that this causes users to perceive things taking longer than they really have. If you don't show anything, users perceive it as being faster. -So your loading component will also get a [`pastDelay` prop](#propspastdelay) +So your rendering component will also get a [`pastDelay` prop](#propspastdelay) which will only be true once the component has taken longer to load than a set [delay](#optsdelay). -```js -function Loading(props) { - if (props.error) { - return
Error!
; - } else if (props.pastDelay) { - return
Loading...
; - } else { - return null; - } -} -``` - This delay defaults to `200ms` but you can also customize the -[delay](#optsdelay) in `Loadable`. +[delay](#optsdelay) in `chunk` and `chunks`. ```js -Loadable({ - loader: () => import('./components/Bar'), - loading: Loading, +chunk(() => import('./components/Bar'), { delay: 300, // 0.3 seconds }); ``` @@ -274,94 +274,77 @@ Sometimes network connections suck and never resolve or fail, they just hang there forever. This sucks for the user because they won't know if it should always take this long, or if they should try refreshing. -The [loading component](#loadingcomponent) will receive a +The rendering component will receive a [`timedOut` prop](#propstimedout) which will be set to `true` when the [`loader`](#optsloader) has timed out. -```js -function Loading(props) { - if (props.error) { - return
Error!
; - } else if (props.timedOut) { - return
Taking a long time...
; - } else if (props.pastDelay) { - return
Loading...
; - } else { - return null; - } -} -``` - However, this feature is disabled by default. To turn it on, you can pass a -[`timeout` option](#optstimeout) to `Loadable`. +[`timeout` option](#optstimeout) to `chunk` and `chunks`. ```js -Loadable({ - loader: () => import('./components/Bar'), - loading: Loading, +chunk(() => import('./components/Bar'), { timeout: 10000, // 10 seconds }); ``` -### Customizing rendering +### Customize rendering -By default `Loadable` will render the `default` export of the returned module. +By default `chunk` and `chunks` will render the `default` export of each returned import. If you want to customize this behavior you can use the -[`render` option](#optsrender). +[`resolveDefaultImport` option](#optsresolveDefaultImport). + +#### Chunk rendering without a rendering component ```js -Loadable({ - loader: () => import('./my-component'), - render(loaded, props) { - let Component = loaded.namedExport; - return ; - } -}); + +// Notice the HOC is invoked with no component +const MyComponentChunk = chunk(() => import('./myComponent'))(); + ``` -### Loading multiple resources +When no rendering component is provided, `null` is rendered until the component **hasLoaded**. -Technically you can do whatever you want within `loader()` as long as it -returns a promise and [you're able to render something](#customizing-rendering). -But writing it out can be a bit annoying. + +#### Rendering multiple chunks + +`chunks` **requires** a rendering component be provided when invoking the HOC, an error will be thrown if this requirement is not met. + + +### Loading multiple resources To make it easier to load multiple resources in parallel, you can use -[`Loadable.Map`](#loadablemap). +[`chunks`](#chunks). + +When using `chunks` a rendering component **must** be provided when invoking the HOC. + +#### Using `chunks` for multiple imports ```js -Loadable.Map({ - loader: { - Bar: () => import('./Bar'), - i18n: () => fetch('./i18n/bar.json').then(res => res.json()), - }, - render(loaded, props) { - let Bar = loaded.Bar.default; - let i18n = loaded.i18n; - return ; - }, -}); +const MultiComponentChunk = chunks({ + Bar: () => import('./Bar'), + i18n: () => fetch('./i18n/bar.json').then(res => res.json()) +}, { + delay: 300, + // other options here... +})(RequiredRendererComponent); ``` -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`. ### Preloading -As an optimization, you can also decide to preload a component before it gets +As an optimization, you can also decide to preload one or more components before being rendered. +#### Preload a single chunk + For example, if you need to load a new component when a button gets pressed, you could start preloading the component when the user hovers over the button. -The component created by `Loadable` exposes a +The components created by `chunk` and `chunks` expose a [static `preload` method](#loadablecomponentpreload) which does exactly this. ```js -const LoadableBar = Loadable({ - loader: () => import('./Bar'), - loading: Loading, -}); +const BarChunk = chunk(() => import('./Bar'))(); class MyComponent extends React.Component { state = { showBar: false }; @@ -371,7 +354,7 @@ class MyComponent extends React.Component { }; onMouseOver = () => { - LoadableBar.preload(); + BarChunk.preload(); }; render() { @@ -382,208 +365,140 @@ class MyComponent extends React.Component { onMouseOver={this.onMouseOver}> Show Bar - {this.state.showBar && } + {this.state.showBar && } ) } } ``` -

-
-
- SERVER SIDE RENDERING -
-
- Server-Side Rendering -

+#### Preload multiple chunks -When you go to render all these dynamically loaded components, what you'll get -is a whole bunch of loading screens. - -This really sucks, but the good news is that React Loadable is designed to -make server-side rendering work as if nothing is being loaded dynamically. +This approach can be used to load all the chunks required for rendering a route on the client, and ensure that all chunks are loaded before rendering the route. -Here's our starting server using [Express](https://expressjs.com/). +This makes it easier to handle errors, instead of having to render an error for each failed component on the page (which may result in the user seeing many error messages) you can simply render an error page for the user - and allow the user to retry the previous action if desired. ```js -import express from 'express'; -import React from 'react'; -import ReactDOMServer from 'react-dom/server'; -import App from './components/App'; +import { preloadChunks } from 'react-chunk'; -const app = express(); +const FooChunk = chunk(() => import('./Foo'))(); +const BarChunk = chunk(() => import('./Bar'))(); -app.get('/', (req, res) => { - res.send(` - - - ... - -
${ReactDOMServer.renderToString()}
- - - - `); -}); +preloadChunks([ + FooChunk.getLoader(), + BarChunk.getLoader(), +]).then(() => { + // use 'setState()' to render using the loaded components +}).catch(err => { + // handle timeouts, or other errors +}) -app.listen(3000, () => { - console.log('Running on http://localhost:3000/'); -}); ``` -### Preloading all your loadable components on the server +## Server-Side Rendering + +When you go to render all these dynamically loaded components, what you'll get +is a whole bunch of loading screens. + +This really sucks, but the good news is that React Chunk is designed to +make server-side rendering work as if nothing is being imported dynamically. + +### Preloading all your chunk components on the server The first step to rendering the correct content from the server is to make sure -that all of your loadable components are already loaded when you go to render +that all of your chunk components are already loaded when you go to render them. -To do this, you can use the [`Loadable.preloadAll`](#loadablepreloadall) -method. It returns a promise that will resolve when all your loadable +To do this, you can use the [`preloadAll`](#loadablepreloadall) +method. It returns a promise that will resolve when all your chunk components are ready. ```js -Loadable.preloadAll().then(() => { +import { preloadAll } from 'react-chunk'; + +preloadAll().then(() => { app.listen(3000, () => { console.log('Running on http://localhost:3000/'); }); }); ``` -### Picking up a server-side rendered app on the client - -This is where things get a little bit tricky. So let's prepare ourselves -little bit. - -In order for us to pick up what was rendered from the server we need to have -all the same code that was used to render on the server. - -To do this, we first need our loadable components telling us which modules they -are rendering. - -#### Declaring which modules are being loaded - -There are two options in [`Loadable`](#loadable) and -[`Loadable.Map`](#loadablemap) which are used to tell us which modules our -component is trying to load: [`opts.modules`](#optsmodules) and -[`opts.webpack`](#optswebpack). - -```js -Loadable({ - loader: () => import('./Bar'), - modules: ['./Bar'], - webpack: () => [require.resolveWeak('./Bar')], -}); -``` - -But don't worry too much about these options. React Loadable includes a -[Babel plugin](#babel-plugin) to add them for you. - -Just add the `react-loadable/babel` plugin to your Babel config: +#### Configure babel and webpack -```json -{ - "plugins": [ - "react-loadable/babel" - ] -} -``` +Ensure you have [configured babel and webpack](#environmentconfiguration) for **both** _client_ and _server_ builds. -Now these options will automatically be provided. +The babel plugin adds additional information to all of your `chunk` and `chunks`. -#### Finding out which dynamic modules were rendered +#### Tracking which dynamic modules were rendered -Next we need to find out which modules were actually rendered when a request -comes in. +Next we need to find out which chunks were used to perform the server render. -For this, there is [`Loadable.Capture`](#loadablecapture) component which can -be used to collect all the modules that were rendered. +For this, there is the [`Recorder`](#chunkrecorder) component which can +be used to record all the chunks used for rendering. ```js -import Loadable from 'react-loadable'; +import ChunkRecorder from 'react-chunk/Recorder'; app.get('/', (req, res) => { - let modules = []; + let renderedChunks = []; let html = ReactDOMServer.renderToString( - modules.push(moduleName)}> + renderedChunks.push(chunkName)}> - + ); - console.log(modules); + console.log(renderedChunks); res.send(`...${html}...`); }); ``` -#### Mapping loaded modules to bundles - -In order to make sure that the client loads all the modules that were rendered -server-side, we'll need to map them to the bundles that Webpack created. - -This comes in two parts. - -First we need Webpack to tell us which bundles each module lives inside. For -this there is the [React Loadable Webpack plugin](#webpack-plugin). - -Import the `ReactLoadablePlugin` from `react-loadable/webpack` and include it -in your webpack config. Pass it a `filename` for where to store the JSON data -about our bundles. - -```js -// webpack.config.js -import { ReactLoadablePlugin } from 'react-loadable/webpack'; +#### Resolving rendered chunks -export default { - plugins: [ - new ReactLoadablePlugin({ - filename: './dist/react-loadable.json', - }), - ], -}; -``` +In order to make sure that the client loads all the resources required by the +server-side render, we need to resolve the chunks that Webpack created. -Then we'll go back to our server and use this data to convert our modules to -bundles. +First we need to configure Webpack to write the chunk data to a file. Use the [React Chunk Webpack plugin](#webpack-plugin). -To convert from modules to bundles, import the [`getBundles`](#getbundles) -method from `react-loadable/webpack` and the data from Webpack. +Then we can use the plugin output to determine the chunks required for the client render. To determine the files required for each chunk, import the [`resolveChunks`](#resolveChunks) +method from `react-chunk/webpack` and the data from Webpack. ```js -import Loadable from 'react-loadable'; -import { getBundles } from 'react-loadable/webpack' -import stats from './dist/react-loadable.json'; +import ChunkRecorder from 'react-chunk/Recorder'; +import { resolveChunks } from 'react-chunk/webpack' +import chunkData from './dist/react-chunk.json'; app.get('/', (req, res) => { - let modules = []; + let renderedChunks = []; let html = ReactDOMServer.renderToString( - modules.push(moduleName)}> + renderedChunks.push(chunkName)}> - + ); - let bundles = getBundles(stats, modules); + let resources = resolveChunks(chunkData, renderedChunks); // ... }); ``` -We can then render these bundles into `` + }).join('\n')} +
${html}
- ${bundles.map(bundle => { + ${scripts.map(bundle => { return `` }).join('\n')} @@ -611,23 +535,26 @@ res.send(` `); ``` -#### Preloading ready loadable components on the client +#### Preloading resolved chunks on the client -We can use the [`Loadable.preloadReady()`](#loadablepreloadready) method on the -client to preload the loadable components that were included on the page. +We can use the [`preloadReady()`](#loadablepreloadready) method on the +client to preload the chunk components that were included on the page. -Like [`Loadable.preloadAll()`](#loadablepreloadall), it returns a promise, +Like [`preloadAll()`](#loadablepreloadall), it returns a promise, which on resolution means that we can hydrate our app. ```js // src/entry.js import React from 'react'; import ReactDOM from 'react-dom'; -import Loadable from 'react-loadable'; +import { preloadReady } from 'react-chunk'; import App from './components/App'; -Loadable.preloadReady().then(() => { +preloadReady().then(() => { ReactDOM.hydrate(, document.getElementById('app')); +}).catch(err => { + // errors can occur if imports timeout or fail + // render an error page }); ``` @@ -636,196 +563,153 @@ Loadable.preloadReady().then(() => { Now server-side rendering should work perfectly! -

-
-
- API DOCS -
-
- API Docs -

+## API -### `Loadable` +### `chunk` -A higher-order component for dynamically [loading](#optsloader) a module before -[rendering](#optsrender) it, a [loading](#opts.loading) component is rendered -while the module is unavailable. +A higher-order component for dynamically importing a single resource. + +`chunk(import: function[, options: Object]): ChunkComponent` ```js -const LoadableComponent = Loadable({ - loader: () => import('./Bar'), - loading: Loading, +import { chunk } from 'react-chunk'; + +const ChunkComponent = chunk(() => import('./Bar'), { delay: 200, timeout: 10000, -}); +})([WrappedComponent]); ``` -This returns a [LoadableComponent](#loadablecomponent). +This returns a [ChunkComponent](#chunkcomponent). The `WrappedComponent` for a `chunk` is optional, but recommended for complete control of the rendering. The `WrappedComponent` will be passed an additional single prop `chunk`, that provides all state required to render the imported resource. -### `Loadable.Map` +### `chunks` 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. +`chunks(importMap: {[string]: function}[, options: Object]): ChunksComponent` + ```js -Loadable.Map({ - loader: { - Bar: () => import('./Bar'), - i18n: () => fetch('./i18n/bar.json').then(res => res.json()), - }, - render(loaded, props) { - let Bar = loaded.Bar.default; - let i18n = loaded.i18n; - return ; - } -}); +import { chunks } from 'react-chunk'; + +const ChunksComponent = chunks({ + Foo: () => import('./Foo'), + Bar: () => import('./Bar') +}, { + // define options here... + delay: 200, + timeout: 10000, +})(WrappedComponent); ``` -When using `Loadable.Map` the `render()` method's `loaded` param will be an -object with the same shape as your `loader`. +This returns a [ChunksComponent](#chunkscomponent). The `WrappedComponent` for a `chunks` is required to control rendering of all imported resources. The `WrappedComponent` will be passed an additional single prop `chunk`, that provides all state required to render the imported resource. -### `Loadable` and `Loadable.Map` Options -#### `opts.loader` +### `chunk` and `chunks` Options -A function returning a promise that loads your module. +#### `opts.displayName: string` -```js -Loadable({ - loader: () => import('./Bar'), -}); -``` +The react display name to assign when creating the HOC. -When using with [`Loadable.Map`](#loadablemap) this accepts an object of these -types of functions. +#### `opts.hoist: boolean` -```js -Loadable.Map({ - loader: { - Bar: () => import('./Bar'), - i18n: () => fetch('./i18n/bar.json').then(res => res.json()), - }, -}); -``` +`true` to _hoist_ non-react static methods of the imported component to the HOC. Defaults to `false`. -When using with `Loadable.Map` you'll also need to pass a -[`opts.render`](#optsrender) function. +Note that the static methods are only hoisted after the component is loaded (obviously) - if you're using `hoist: true` on a component its _recommended_ that you `preload` (or `preloadChunks`) the component to avoid invoking static methods that have not yet been assigned to the HOC. -#### `opts.loading` +Using this option with `chunks` is not supported and will result in an error. -A [`LoadingComponent`](#loadingcomponent) that renders while a module is -loading or when it errors. +#### `opts.resolveDefaultImport: (imported, importKey) => mixed` -```js -Loadable({ - loading: LoadingComponent, -}); -``` +By default, the `.default` export of the imported resource is returned to the `Imported` property (for `chunk`) or the `imported` property (for `chunks`). -This option is required, if you don't want to render anything, return `null`. +The `importKey` is only passed for `chunks`. -```js -Loadable({ - loading: () => null, -}); -``` +#### `opts.retryBackOff: Array` + +Allows automatic retry for failed imports using the assigned backOff. -#### `opts.delay` +When used in conjuntion with `timeout`, retry attempts will be invoked after the configured `timeout` value has expired. + +For example: `[250, 500]` will result in the first retry attempt starting 250ms **after** the first `timeout` or `error`. The second retry will start 500ms **after** the second _timeout_ or _error_. + + +#### `opts.delay: number` Time to wait (in milliseconds) before passing [`props.pastDelay`](#propspastdelay) to your [`loading`](#optsloading) component. This defaults to `200`. -```js -Loadable({ - delay: 200 -}); -``` - [Read more about delays](#avoiding-flash-of-loading-component). -#### `opts.timeout` +#### `opts.timeout: number` Time to wait (in milliseconds) before passing [`props.timedOut`](#propstimedout) to your [`loading`](#optsloading) component. This is turned off by default. -```js -Loadable({ - timeout: 10000 -}); -``` - [Read more about timeouts](#timing-out-when-the-loader-is-taking-too-long). -#### `opts.render` +#### `opts.webpack: function` -A function to customize the rendering of loaded modules. - -Receives `loaded` which is the resolved value of [`opts.loader`](#optsloader) -and `props` which are the props passed to the -[`LoadableComponent`](#loadablecomponent). +An optional function which returns an array of Webpack module ids which you can +get with `require.resolveWeak`. ```js -Loadable({ - render(loaded, props) { - let Component = loaded.default; - return ; - } +chunk(() => import('./component'), { + webpack: () => [require.resolveWeak('./Foo')], }); ``` -#### `opts.webpack` +This option can be automated with the [Babel Plugin](#babel-plugin). + +#### `opts.modules: Array` -An optional function which returns an array of Webpack module ids which you can -get with `require.resolveWeak`. +An optional array with module paths for your imports. ```js -Loadable({ - loader: () => import('./Foo'), - webpack: () => [require.resolveWeak('./Foo')], +chunk(() => import('./component'), { + modules: ['./my-component'] }); ``` This option can be automated with the [Babel Plugin](#babel-plugin). -#### `opts.modules` +### `ChunkComponent` -An optional array with module paths for your imports. +This is the component returned by `chunk`. ```js -Loadable({ - loader: () => import('./my-component'), - modules: ['./my-component'], +const ChunkComponent = chunk({ + // ... }); ``` -This option can be automated with the [Babel Plugin](#babel-plugin). +Props passed to this component will be passed straight through to the +wrapped component, in additional to a `chunk` prop that includes all data required for rendering the imported resource. -### `LoadableComponent` +### `ChunksComponent` -This is the component returned by `Loadable` and `Loadable.Map`. +This is the component returned by `chunks`. ```js -const LoadableComponent = Loadable({ +const ChunksComponent = chunks({ // ... }); ``` Props passed to this component will be passed straight through to the -dynamically loaded component via [`opts.render`](#optsrender). +wrapped component, in additional to a `chunk` prop that includes all data required for rendering the imported resources. -#### `LoadableComponent.preload()` +### Common `chunk` and `chunks` static methods +#### `preload()` -This is a static method on [`LoadableComponent`](#loadablecomponent) which can -be used to load the component ahead of time. +This is a static method that can be used to load the component ahead of time. ```js -const LoadableComponent = Loadable({...}); +const ChunkComponent = chunk({...}); -LoadableComponent.preload(); +ChunkComponent.preload(); ``` This returns a promise, but you should avoid waiting for that promise to @@ -833,38 +717,78 @@ resolve to update your UI. In most cases it creates a bad user experience. [Read more about preloading](#preloading). -### `LoadingComponent` +#### `getLoader()` -This is the component you pass to [`opts.loading`](#optsloading). +This is a static method that can be used to obtain a reference to the components loader. It should be used in conjuntion with [preloadChunks()](#preloadchunks) ```js -function LoadingComponent(props) { - if (props.error) { - // When the loader has errored - return
Error!
; - } else if (props.timedOut) { - // When the loader has taken longer than the timeout - return
Taking a long time...
; - } else if (props.pastDelay) { - // When the loader has taken longer than the delay +const ChunkComponent = chunk({...}); + +ChunkComponent.getLoader(); +``` + + +### `WrappedComponent` + +This is the component you pass to the `chunk()` or `chunks()` HOC. + +```js +function WrappedComponent(props) { + const { + chunk: { + isLoading, + hasLoaded, + pastDelay, + timedOut, + error, + retry, + loaded, + Imported // - only for 'chunk()' + // imported - only for 'chunks()' + // importKeys - only for 'chunks()' + }, + ...restProps + } = prop; + + if (hasLoaded) { + return ; + } + + if (error) { + return
An error occured
; + } + + if (timedOut) { + return ( +
+ This is taking a while.. + retry()}>retry? +
+ ); + } + + if (isLoading && pastDelay) { return
Loading...
; - } else { - // When the loader has just started - return null; } + + return null; } -Loading({ - loading: LoadingComponent, -}); ``` [Read more about loading components](#creating-a-great-loading-component) -#### `props.error` +#### `chunk.isLoading: boolean` + +`true` if the import(s) are currently being loaded, otherwise `false`. + +#### `chunk.hasLoaded: boolean` -A boolean prop passed to [`LoadingComponent`](#loadingcomponent) when the -[`loader`](#optsloader) has failed. +`true` if the import(s) have been successfully loaded, otherwise `false`. + +#### `chunk.error: boolean` + +A boolean prop passed to [`WrappedComponent`](#wrappedcomponent) when the loading resource(s) has failed. ```js function LoadingComponent(props) { @@ -878,9 +802,9 @@ function LoadingComponent(props) { [Read more about errors](#loading-error-states). -#### `props.timedOut` +#### `chunk.timedOut: boolean` -A boolean prop passed to [`LoadingComponent`](#loadingcomponent) after a set +A boolean prop passed to [`WrappedComponent`](#wrappedcomponent) after a set [`timeout`](#optstimeout). ```js @@ -895,9 +819,9 @@ function LoadingComponent(props) { [Read more about timeouts](#timing-out-when-the-loader-is-taking-too-long). -#### `props.pastDelay` +#### `chunk.pastDelay: boolean` -A boolean prop passed to [`LoadingComponent`](#loadingcomponent) after a set +A boolean prop passed to [`WrappedComponent`](#wrappedcomponent) after a set [`delay`](#optsdelay). ```js @@ -912,22 +836,23 @@ function LoadingComponent(props) { [Read more about delays](#avoiding-flash-of-loading-component). -### `Loadable.preloadAll()` +### `preloadAll()` This will call all of the -[`LoadableComponent.preload`](#loadablecomponentpreload) methods recursively +[`WrappedComponent.preload`](#wrappedcomponentpreload) methods recursively until they are all resolved. Allowing you to preload all of your dynamic modules in environments like the server. ```js -Loadable.preloadAll().then(() => { +import { preloadAll } from 'react-chunk'; +preloadAll().then(() => { app.listen(3000, () => { console.log('Running on http://localhost:3000/'); }); }); ``` -It's important to note that this requires that you declare all of your loadable +It's important to note that this requires that you declare all of your chunk components when modules are initialized rather than when your app is being rendered. @@ -935,7 +860,7 @@ rendered. ```js // During module initialization... -const LoadableComponent = Loadable({...}); +const LoadableComponent = chunk(...); class MyComponent extends React.Component { componentDidMount() { @@ -952,46 +877,48 @@ class MyComponent extends React.Component { class MyComponent extends React.Component { componentDidMount() { // During app render... - const LoadableComponent = Loadable({...}); + const LoadableComponent = chunk(...); } } ``` -> **Note:** `Loadable.preloadAll()` will not work if you have more than one -> copy of `react-loadable` in your app. +> **Note:** `preloadAll()` will not work if you have more than one +> copy of `react-chunk` in your app. -[Read more about preloading on the server](#preloading-all-your-loadable-components-on-the-server). +[Read more about preloading on the server](#preloading-all-your-chunk-components-on-the-server). -### `Loadable.preloadReady()` +### `preloadReady()` Check for modules that are already loaded in the browser and call the matching -[`LoadableComponent.preload`](#loadablecomponentpreload) methods. +[`WrappedComponent.preload`](#wrappedcomponentpreload) methods. ```js -Loadable.preloadReady().then(() => { +import { preloadReady } from 'react-chunk'; +preloadReady().then(() => { ReactDOM.hydrate(, document.getElementById('app')); }); ``` [Read more about preloading on the client](#waiting-to-render-on-the-client-until-all-the-bundles-are-loaded). -### `Loadable.Capture` +### `Recorder` -A component for reporting which modules were rendered. +A component for reporting which chunks were used for rendering. -Accepts a `report` prop which is called for every `moduleName` that is -rendered via React Loadable. +Accepts an `addChunk` prop which is called for every `chunkName` that is +rendered via React Chunk. ```js -let modules = []; +import ChunkRecorder from 'react-chunk/Recorder'; +let renderedChunks = []; let html = ReactDOMServer.renderToString( - modules.push(moduleName)}> + renderedChunks.push(chunkName)}> - + ); -console.log(modules); +console.log(renderedChunks); ``` [Read more about capturing rendered modules](#finding-out-which-dynamic-modules-were-rendered). @@ -999,53 +926,53 @@ console.log(modules); ## Babel Plugin Providing [`opts.webpack`](#optswebpack) and [`opts.modules`](#optsmodules) for -every loadable component is a lot of manual work to remember to do. +every chunk component is a lot of manual work to remember to do. Instead you can add the Babel plugin to your config and it will automate it for you: ```json { - "plugins": ["react-loadable/babel"] + "plugins": ["react-chunk/babel"] } ``` **Input** ```js -import Loadable from 'react-loadable'; +import { chunk, chunks } from 'react-chunk'; -const LoadableMyComponent = Loadable({ - loader: () => import('./MyComponent'), -}); +const ChunkMyComponent = chunk(() => import('./MyComponent')); -const LoadableComponents = Loadable.Map({ - loader: { - One: () => import('./One'), - Two: () => import('./Two'), - }, +const ChunkComponents = chunks({ + One: () => import('./One'), + Two: () => import('./Two'), }); ``` **Output** ```js -import Loadable from 'react-loadable'; -import path from 'path'; - -const LoadableMyComponent = Loadable({ - loader: () => import('./MyComponent'), - webpack: () => [require.resolveWeak('./MyComponent')], - modules: [path.join(__dirname, './MyComponent')], +import { chunk, chunks } from 'react-chunk'; + +const ChunkMyComponent = chunk( + () => import('./MyComponent'), + {}, + { + webpack: () => [require.resolveWeak('./MyComponent')], + modules: ['./MyComponent'] + } }); -const LoadableComponents = Loadable.Map({ - loader: { +const ChunkComponents = chunks({ One: () => import('./One'), Two: () => import('./Two'), }, - webpack: () => [require.resolveWeak('./One'), require.resolveWeak('./Two')], - modules: [path.join(__dirname, './One'), path.join(__dirname, './Two')], + {}, + { + webpack: () => [require.resolveWeak('./One'), require.resolveWeak('./Two')], + modules: ['./One', './Two'] + } }); ``` @@ -1054,17 +981,17 @@ const LoadableComponents = Loadable.Map({ ## Webpack Plugin In order to [send the right bundles down](#mapping-loaded-modules-to-bundles) -when rendering server-side, you'll need the React Loadable Webpack pluginĀ  +when rendering server-side, you'll need the React Chunk Webpack pluginĀ  to provide you with a mapping of modules to bundles. ```js // webpack.config.js -import { ReactLoadablePlugin } from 'react-loadable/webpack'; +import { ReactChunkPlugin } from 'react-chunk/webpack'; export default { plugins: [ - new ReactLoadablePlugin({ - filename: './dist/react-loadable.json', + new ReactChunkPlugin({ + filename: './dist/react-chunk.json', }), ], }; @@ -1073,96 +1000,91 @@ export default { This will create a file (`opts.filename`) which you can import to map modules to bundles. +### `opts.filename` +Required, the destination file for writing react-chunk module data + +### `opts.ignoreChunkNames` +Optional, an array of webpack chunk names to exclude from the module data + +By ignoring the main entry point (ie: `main` or `index`) only required module data is included in the output. + [Read more about mapping modules to bundles](#mapping-loaded-modules-to-bundles). -### `getBundles` +### `resolveChunks` -A method exported by `react-loadable/webpack` for converting modules to -bundles. +A method exported by `react-chunk/webpack` for converting chunks to +resources. ```js -import { getBundles } from 'react-loadable/webpack'; +import { resolveChunks } from 'react-chunk/webpack'; -let bundles = getBundles(stats, modules); +let resources = resolveChunks(chunkData, renderedChunks); ``` [Read more about mapping modules to bundles](#mapping-loaded-modules-to-bundles). -

-
-
- FAQ -
-
- FAQ -

+## FAW ### How do I avoid repetition? Specifying the same `loading` component or `delay` every time you use -`Loadable()` gets repetitive fast. Instead you can wrap `Loadable` with your +`chunk()` or `chunks()` gets repetitive fast. Instead you can wrap `chunk` and `chunks` with your own Higher-Order Component (HOC) to set default options. ```js -import Loadable from 'react-loadable'; -import Loading from './my-loading-component'; - -export default function MyLoadable(opts) { - return Loadable(Object.assign({ - loading: Loading, - delay: 200, - timeout: 10, - }, opts)); +// chunkOptions.js +const defaultChunkOpts = { + delay: 200, + timeout: 10, }; -``` -Then you can just specify a `loader` when you go to use it. +export default defaultChunkOpts; +``` ```js -import MyLoadable from './MyLoadable'; +import { chunk chunks } from 'react-chunk'; +import Loading from './my-loading-component'; +import defaultChunkOpts form './chunkOptions'; -const LoadableMyComponent = MyLoadable({ - loader: () => import('./MyComponent'), -}); -export default class App extends React.Component { - render() { - return ; - } -} +export default function MyComponentChunk(opts = {}) { + return chunk( + () => import('./my-component'), + Object.assign({}, defaultChunkOpts, opts) + ); +}; ``` -Unfortunately at the moment using wrapped Loadable breaks [react-loadable/babel](#babel-plugin) so in such case you have to add required properties (`modules`, `webpack`) manually. +Then you can specify additional `options` and a `WrappedComponent` when you go to use it. ```js -import MyLoadable from './MyLoadable'; +import MyComponentChunk from './MyComponentChunk'; +import ChunkRenderer from './ChunkRenderer'; -const LoadableMyComponent = MyLoadable({ - loader: () => import('./MyComponent'), - modules: ['./MyComponent'], - webpack: () => [require.resolveWeak('./MyComponent')], -}); +const MyAutoRetryComponentChunk = MyComponentChunk({ + retryBackOff: [200, 300] +})(ChunkRenderer); export default class App extends React.Component { render() { - return ; + return ; } } ``` ### How do I handle other styles `.css` or sourcemaps `.map` with server-side rendering? -When you call [`getBundles`](#getbundles), it may return file types other than +When you call [`resolveChunks`](#resolveChunks), it may return file types other than JavaScript depending on your Webpack configuration. To handle this, you should manually filter down to the file extensions that you care about: ```js -let bundles = getBundles(stats, modules); +let resources = resolveChunks(stats, modules); -let styles = bundles.filter(bundle => bundle.file.endsWith('.css')); -let scripts = bundles.filter(bundle => bundle.file.endsWith('.js')); +let styles = resources.filter(bundle => bundle.file.endsWith('.css')); +let scripts = resources.filter(bundle => bundle.file.endsWith('.js')); res.send(` diff --git a/__tests__/__snapshots__/test.js.snap b/__tests__/__snapshots__/test.js.snap index 5e591c1..1f0e98d 100644 --- a/__tests__/__snapshots__/test.js.snap +++ b/__tests__/__snapshots__/test.js.snap @@ -1,215 +1,417 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`delay and timeout 1`] = ` +exports[`chunk delay and timeout 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`delay and timeout 2`] = ` +exports[`chunk delay and timeout 2`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`delay and timeout 3`] = ` +exports[`chunk delay and timeout 3`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":true,"timedOut":true,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":true,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`delay and timeout 4`] = ` +exports[`chunk delay and timeout 4`] = `
MyComponent {"prop":"foo"}
`; -exports[`loadable map error 1`] = ` +exports[`chunk loading error 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`loadable map error 2`] = ` +exports[`chunk loading error 2`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`loadable map error 3`] = ` +exports[`chunk loading error 3`] = `
MyLoadingComponent - {"isLoading":false,"pastDelay":true,"timedOut":false,"error":{}} + {"isLoading":false,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":{},"loaded":null,"importKeys":[]}
`; -exports[`loadable map success 1`] = ` +exports[`chunk loading success 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`loadable map success 2`] = ` +exports[`chunk loading success 2`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`loadable map success 3`] = ` +exports[`chunk loading success 3`] = `
-
- MyComponent - {"prop":"baz"} -
-
- MyComponent - {"prop":"baz"} -
+ MyComponent + {"prop":"foo"}
`; -exports[`loading error 1`] = ` +exports[`chunk loading success 4`] = ` +
+ MyComponent + {"prop":"bar"} +
+`; + +exports[`chunk preload 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk preload 2`] = ` +
+ MyComponent + {"prop":"bar"} +
+`; + +exports[`chunk preload 3`] = ` +
+ MyComponent + {"prop":"baz"}
`; -exports[`loading error 2`] = ` +exports[`chunk render 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`loading error 3`] = ` +exports[`chunk render 2`] = `
MyLoadingComponent - {"isLoading":false,"pastDelay":true,"timedOut":false,"error":{}} + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk render 3`] = ` +
+ MyComponent + {"prop":"baz"}
`; -exports[`loading success 1`] = ` +exports[`chunk render without wrapped component 1`] = `null`; + +exports[`chunk render without wrapped component 2`] = `null`; + +exports[`chunk render without wrapped component 3`] = ` +
+ MyComponent + {"prop":"foo"} +
+`; + +exports[`chunk resolveDefaultImport 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`loading success 2`] = ` +exports[`chunk resolveDefaultImport 2`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`loading success 3`] = ` +exports[`chunk resolveDefaultImport 3`] = `
MyComponent {"prop":"foo"}
`; -exports[`loading success 4`] = ` +exports[`chunk resolveDefaultImport with no wrapped component 1`] = `null`; + +exports[`chunk resolveDefaultImport with no wrapped component 2`] = `null`; + +exports[`chunk resolveDefaultImport with no wrapped component 3`] = `
MyComponent - {"prop":"bar"} + {"prop":"foo"}
`; -exports[`preload 1`] = ` +exports[`chunk retry after error 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`preload 2`] = ` +exports[`chunk retry after error 2`] = `
- MyComponent - {"prop":"baz"} + MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk retry after error 3`] = ` +
+ {"isLoading":false,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":{},"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk retry after error 4`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":{},"importKeys":[]}
`; -exports[`preload 3`] = ` +exports[`chunk retry after error 5`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":{},"importKeys":[]} +
+`; + +exports[`chunk retry after error 6`] = `
MyComponent - {"prop":"baz"} + {"prop":"foo"}
`; -exports[`preloadReady delay with 0 1`] = ` +exports[`chunk retryBackOff 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`preloadReady many 1`] = ` +exports[`chunk retryBackOff 2`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk retryBackOff 3`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk retryBackOff 4`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk retryBackOff 5`] = `
MyComponent - {"prop":"baz"} + {"prop":"foo"}
`; -exports[`preloadReady missing 1`] = ` +exports[`chunk server side rendering 1`] = ` +
+ fixture1 +
+`; + +exports[`chunk server side rendering es6 1`] = ` +
+ fixture2 +
+`; + +exports[`chunk timeout and retryBackOff 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; -exports[`preloadReady one 1`] = ` +exports[`chunk timeout and retryBackOff 2`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk timeout and retryBackOff 3`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk timeout and retryBackOff 4`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk timeout and retryBackOff 5`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":true,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`chunk timeout and retryBackOff 6`] = `
MyComponent - {"prop":"baz"} + {"prop":"foo"}
`; -exports[`preloadReady undefined 1`] = ` +exports[`chunks loads multiple imports 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":{},"importKeys":[]}
`; -exports[`render 1`] = ` +exports[`chunks loads multiple imports 2`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":false,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":{},"importKeys":[]}
`; -exports[`render 2`] = ` +exports[`chunks loads multiple imports 3`] = ` +Array [ +
+ MyComponent + {"prop":"baz"} +
, +
+ MyComponent + {"prop":"baz"} +
, +] +`; + +exports[`chunks renders error when import fails 1`] = `
MyLoadingComponent - {"isLoading":true,"pastDelay":true,"timedOut":false,"error":null} + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":{},"importKeys":[]}
`; -exports[`render 3`] = ` +exports[`chunks renders error when import fails 2`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":{},"importKeys":[]} +
+`; + +exports[`chunks renders error when import fails 3`] = ` +
+ MyLoadingComponent + {"isLoading":false,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":{},"loaded":{},"importKeys":[]} +
+`; + +exports[`chunks resolveDefaultImport 1`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":{},"importKeys":[]} +
+`; + +exports[`chunks resolveDefaultImport 2`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":{},"importKeys":[]} +
+`; + +exports[`chunks resolveDefaultImport 3`] = ` +Array [ +
+ MyComponent + {"prop":"foo"} +
, +
+ MyComponent + {"prop":"foo"} +
, +] +`; + +exports[`preloadChunks 1`] = `
MyComponent {"prop":"baz"}
`; -exports[`server side rendering 1`] = ` +exports[`preloadChunks 2`] = `
- fixture1 + MyComponent + {"prop":"foo"}
`; -exports[`server side rendering es6 1`] = ` +exports[`preloadReady delay with 0 1`] = `
- fixture2 + MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":true,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`preloadReady many 1`] = ` +
+ MyComponent + {"prop":"baz"} +
+`; + +exports[`preloadReady missing 1`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]} +
+`; + +exports[`preloadReady one 1`] = ` +
+ MyComponent + {"prop":"baz"} +
+`; + +exports[`preloadReady undefined 1`] = ` +
+ MyLoadingComponent + {"isLoading":true,"hasLoaded":false,"pastDelay":false,"timedOut":false,"error":null,"loaded":null,"importKeys":[]}
`; diff --git a/__tests__/test.js b/__tests__/test.js index a241447..9ef8541 100644 --- a/__tests__/test.js +++ b/__tests__/test.js @@ -1,9 +1,7 @@ 'use strict'; - -const path = require('path'); const React = require('react'); const renderer = require('react-test-renderer'); -const Loadable = require('../src'); +const { chunk, chunks, preloadChunks, preloadReady, preloadAll } = require('../src'); function waitFor(delay) { return new Promise(resolve => { @@ -23,8 +21,43 @@ function createLoader(delay, loader, error) { }; } -function MyLoadingComponent(props) { - return
MyLoadingComponent {JSON.stringify(props)}
; +function SingleImport({ chunk, ...rest }) { + const { Imported, ...chunkState } = chunk; + if (chunkState.hasLoaded) { + return ; + } + + return
MyLoadingComponent {JSON.stringify(chunkState)}
; +} + +// Allows '_' prefix to infer a named component +function lookupMapComponent(key, imports) { + if (key.indexOf('_') === 0) { + return imports[key][key.substring(1)]; + } + + return imports[key]; +} + +function MapImport({ chunk, ...rest }) { + const { imported, ...chunkState } = chunk; + const { isLoading, hasLoaded, error, importKeys } = chunkState; + + if (isLoading || error) { + return
MyLoadingComponent {JSON.stringify(chunkState)}
; + } + + if (hasLoaded) { + return ( + + {importKeys.map((key, idx) => { + return React.createElement(lookupMapComponent(key, imported), { ...rest, key: idx }) + })} + + ); + } + + return null; } function MyComponent(props) { @@ -33,172 +66,387 @@ function MyComponent(props) { afterEach(async () => { try { - await Loadable.preloadAll(); + await preloadAll(); } catch (err) {} }); -test('loading success', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(400, () => MyComponent), - loading: MyLoadingComponent +describe('chunk', () => { + test('missing import throws', async () => { + expect(() => chunk({a: createLoader(400, () => MyComponent)})).toThrow(); + }); + + test('render', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => MyComponent))(SingleImport); + + 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 }); - let component1 = renderer.create(); + test('loading success', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => MyComponent))(SingleImport); - expect(component1.toJSON()).toMatchSnapshot(); // initial - await waitFor(200); - expect(component1.toJSON()).toMatchSnapshot(); // loading - await waitFor(200); - expect(component1.toJSON()).toMatchSnapshot(); // loaded + let component1 = renderer.create(); - let component2 = renderer.create(); + expect(component1.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component1.toJSON()).toMatchSnapshot(); // loading + await waitFor(200); + expect(component1.toJSON()).toMatchSnapshot(); // loaded - expect(component2.toJSON()).toMatchSnapshot(); // reload -}); + let component2 = renderer.create(); -test('delay and timeout', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(300, () => MyComponent), - loading: MyLoadingComponent, - delay: 100, - timeout: 200, + expect(component2.toJSON()).toMatchSnapshot(); // reload }); - let component1 = renderer.create(); + test('delay and timeout', async () => { + let ChunkMyComponent = chunk(createLoader(300, () => MyComponent), { + delay: 100, + timeout: 200, + })(SingleImport); + + let component1 = renderer.create(); + + expect(component1.toJSON()).toMatchSnapshot(); // initial + await waitFor(100); + expect(component1.toJSON()).toMatchSnapshot(); // loading + await waitFor(100); + expect(component1.toJSON()).toMatchSnapshot(); // timed out + await waitFor(100); + expect(component1.toJSON()).toMatchSnapshot(); // loaded + }); - expect(component1.toJSON()).toMatchSnapshot(); // initial - await waitFor(100); - expect(component1.toJSON()).toMatchSnapshot(); // loading - await waitFor(100); - expect(component1.toJSON()).toMatchSnapshot(); // timed out - await waitFor(100); - expect(component1.toJSON()).toMatchSnapshot(); // loaded -}); + test('loading error', async () => { + let ChunkMyComponent = chunk(createLoader(400, null, new Error('test error')))(SingleImport); + + let component = renderer.create(); -test('loading error', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(400, null, new Error('test error')), - loading: MyLoadingComponent + expect(component.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // loading + await waitFor(200); + expect(component.toJSON()).toMatchSnapshot(); // errored }); - let component = renderer.create(); + test('retryBackOff', async () => { + const mockImport = jest.fn(); + mockImport + .mockImplementationOnce(createLoader(300, null, new Error('1'))) + .mockImplementationOnce(createLoader(200, null, new Error('2'))) + .mockImplementationOnce(createLoader(200, () => MyComponent)); + + let ChunkMyComponent = chunk(mockImport, { + retryBackOff: [200, 300] + })(SingleImport); + + let component1 = renderer.create(); + + expect(component1.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component1.toJSON()).toMatchSnapshot(); // loading + await waitFor(100); + expect(component1.toJSON()).toMatchSnapshot(); // first retry - loading + await waitFor(400); + expect(component1.toJSON()).toMatchSnapshot(); // second retry - loading + await waitFor(600); + expect(component1.toJSON()).toMatchSnapshot(); // loaded + }); - expect(component.toJSON()).toMatchSnapshot(); // initial - await waitFor(200); - expect(component.toJSON()).toMatchSnapshot(); // loading - await waitFor(200); - expect(component.toJSON()).toMatchSnapshot(); // errored -}); + test('timeout and retryBackOff', async () => { + const mockImport = jest.fn(); + mockImport + .mockImplementationOnce(createLoader(300, null, new Error('1'))) + .mockImplementationOnce(createLoader(200, null, new Error('2'))) + .mockImplementationOnce(createLoader(800, () => MyComponent)); + + let ChunkMyComponent = chunk(mockImport, { + timeout: 400, // timeout for each import attempt + retryBackOff: [200, 200] + })(SingleImport); + + let component1 = renderer.create(); + + expect(component1.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component1.toJSON()).toMatchSnapshot(); // loading + await waitFor(300); + expect(component1.toJSON()).toMatchSnapshot(); // first retry - loading + await waitFor(400); + expect(component1.toJSON()).toMatchSnapshot(); // second retry - loading + await waitFor(400); + expect(component1.toJSON()).toMatchSnapshot(); // timedOut (after all retry attempts) + await waitFor(400); + expect(component1.toJSON()).toMatchSnapshot(); // loaded + }); + + test('retry after error', async () => { + const mockImport = jest.fn(); + mockImport + .mockImplementationOnce(createLoader(300, null, new Error('1'))) + .mockImplementationOnce(createLoader(300, () => MyComponent)); + + const RetryChild = ({ chunk, children, ...rest }) => { + const { Imported, ...chunkState } = chunk; + if (chunkState.hasLoaded) { + return ; + } + + if (chunkState.error) { + return React.cloneElement(children, chunkState); + } + + return
MyLoadingComponent {JSON.stringify(chunkState)}
; + }; + + // Wrap the RetryChild with chunk HOC + let ChunkMyComponent = chunk(mockImport)(RetryChild); + + // 'Child' component is required to enable '.reset()' to be invoked after error + const Child = (props) => (
{JSON.stringify(props)}
); + + // Render + let component1 = renderer.create(); + + expect(component1.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component1.toJSON()).toMatchSnapshot(); // loading + await waitFor(100); + expect(component1.toJSON()).toMatchSnapshot(); // error + + component1.root.findByType(Child).props.retry(); -test('server side rendering', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(400, () => require('../__fixtures__/component')), - loading: MyLoadingComponent, + expect(component1.toJSON()).toMatchSnapshot(); // initial + await waitFor(200); + expect(component1.toJSON()).toMatchSnapshot(); // loading + await waitFor(100); + expect(component1.toJSON()).toMatchSnapshot(); // loaded }); - await Loadable.preloadAll(); - let component = renderer.create(); + test('server side rendering', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => require('../__fixtures__/component')))(SingleImport); - expect(component.toJSON()).toMatchSnapshot(); // serverside -}); + await preloadAll(); + + let component = renderer.create(); -test('server side rendering es6', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(400, () => require('../__fixtures__/component.es6')), - loading: MyLoadingComponent, + expect(component.toJSON()).toMatchSnapshot(); // serverside }); - await Loadable.preloadAll(); + test('server side rendering es6', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => require('../__fixtures__/component.es6')))(SingleImport); - let component = renderer.create(); + await preloadAll(); - expect(component.toJSON()).toMatchSnapshot(); // serverside -}); + let component = renderer.create(); -test('preload', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(400, () => MyComponent), - loading: MyLoadingComponent + expect(component.toJSON()).toMatchSnapshot(); // serverside }); - let promise = LoadableMyComponent.preload(); - await waitFor(200); + test('preload', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => MyComponent))(SingleImport); - let component1 = renderer.create(); + let promise = ChunkMyComponent.preload(); + await waitFor(200); - expect(component1.toJSON()).toMatchSnapshot(); // still loading... - await promise; - expect(component1.toJSON()).toMatchSnapshot(); // success + let component1 = renderer.create(); - let component2 = renderer.create(); - expect(component2.toJSON()).toMatchSnapshot(); // success -}); + expect(component1.toJSON()).toMatchSnapshot(); // still loading... + await promise; + expect(component1.toJSON()).toMatchSnapshot(); // success -test('render', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(400, () => ({ MyComponent })), - loading: MyLoadingComponent, - render(loaded, props) { - return ; - } + let component2 = renderer.create(); + expect(component2.toJSON()).toMatchSnapshot(); // success }); - 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: { - a: createLoader(200, () => ({ MyComponent })), - b: createLoader(400, () => ({ MyComponent })), - }, - loading: MyLoadingComponent, - render(loaded, props) { - return ( -
- - -
- ); - } + test('render without wrapped component', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => MyComponent))(); + + 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('resolveDefaultImport with no wrapped component', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => ({ MyComponent })), { + resolveDefaultImport: imported => imported.MyComponent + })(); + + 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 }); - 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('resolveDefaultImport', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => ({ MyComponent })), { + resolveDefaultImport: imported => imported.MyComponent + })(SingleImport); + + 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 + }); + + describe('opts.hoist: true', () => { + test('applies statics in render lifecycle', async () => { + const jestFn = jest.fn(); + MyComponent.myStatic = jestFn; + let ChunkMyComponent = chunk(createLoader(400, () => MyComponent), { hoist: true })(); + + renderer.create(); + expect(ChunkMyComponent.myStatic).toBeUndefined(); // initial + await waitFor(200); + expect(ChunkMyComponent.myStatic).toBeUndefined(); // loading + await waitFor(200); + expect(ChunkMyComponent.myStatic).toBe(jestFn); // success + delete MyComponent.myStatic; + }); + + test('applies statics when preloaded on server', async () => { + const jestFn = jest.fn(); + MyComponent.myStatic = jestFn; + let ChunkMyComponent = chunk(createLoader(400, () => MyComponent), { hoist: true })(); + + await preloadAll(); + + expect(ChunkMyComponent.myStatic).toBe(jestFn); + delete MyComponent.myStatic; + }); + + test('does not apply statics on error', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => { throw new Error(); }), { + hoist: true + })(); + + renderer.create(); + expect(ChunkMyComponent.myStatic).toBeUndefined(); // initial + await waitFor(200); + expect(ChunkMyComponent.myStatic).toBeUndefined(); // loading + await waitFor(200); + expect(ChunkMyComponent.myStatic).toBeUndefined(); // error + delete MyComponent.myStatic; + }); + + test('throws on server `preloadAll()` when import is invalid', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => { throw new Error('import err'); }), { + hoist: true + })(); + + expect.assertions(1); + try { + await preloadAll(); + } catch (e) { + expect(e.message).toEqual('import err'); + } + }); + + test('throws on `preloadChunks()` when import is invalid', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => { throw new Error('import err'); }), { + hoist: true + })(); + + expect.assertions(1); + try { + await preloadChunks([ChunkMyComponent.getLoader()]); + } catch (e) { + expect(e.message).toEqual('import err'); + } + }); + + test('throws on static `preload()`', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => { throw new Error('import err'); }), { + hoist: true + })(); + + expect.assertions(1); + try { + await ChunkMyComponent.preload(); + } catch (e) { + expect(e.message).toEqual('import err'); + } + }); + + test('`preloadChunks()` hoists statics before render', async () => { + const jestFn = jest.fn(); + MyComponent.myStatic = jestFn; + let ChunkMyComponent = chunk(createLoader(400, () => MyComponent), { hoist: true })(); + + await preloadChunks([ChunkMyComponent.getLoader()]); + + expect(ChunkMyComponent.myStatic).toBe(jestFn); + delete MyComponent.myStatic; + }); + }); }); -test('loadable map error', async () => { - let LoadableMyComponent = Loadable.Map({ - loader: { - a: createLoader(200, () => ({ MyComponent })), - b: createLoader(400, null, new Error('test error')), - }, - loading: MyLoadingComponent, - render(loaded, props) { - return ( -
- - -
- ); - } +describe('chunks', () => { + test('missing component throws', async () => { + expect(() => chunks({a: createLoader(400, () => MyComponent)})()).toThrow(); }); - 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('missing import map throws', async () => { + expect(() => chunks(createLoader(400, () => MyComponent))).toThrow(); + }); + + test('using hoist option throws', async () => { + expect(() => chunks({ a: createLoader(400, () => MyComponent)}, { hoist: true })).toThrow(); + }); + + test('loads multiple imports', async () => { + let ChunkMyComponent = chunks({ + a: createLoader(200, () => MyComponent), + _MyComponent: createLoader(400, () => ({MyComponent})), + })(MapImport); + + 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('renders error when import fails', async () => { + let ChunkMyComponent = chunks({ + a: createLoader(200, () => MyComponent), + b: createLoader(400, null, new Error('test error')) + })(MapImport); + + 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 + }); + + test('resolveDefaultImport', async () => { + let ChunkMyComponent = chunks({ + a: createLoader(200, () => ({ aComponent: MyComponent })), + b: createLoader(400, () => ({ bComponent: MyComponent })), + }, { + resolveDefaultImport: (imported, key) => imported[key + 'Component'] + })(MapImport); + + 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 + }); }); describe('preloadReady', () => { @@ -211,40 +459,33 @@ describe('preloadReady', () => { }); test('undefined', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(200, () => MyComponent), - loading: MyLoadingComponent, - }); + let ChunkMyComponent = chunk(createLoader(200, () => MyComponent))(SingleImport); - await Loadable.preloadReady(); + await preloadReady(); - let component = renderer.create(); + let component = renderer.create(); expect(component.toJSON()).toMatchSnapshot(); }); test('one', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(200, () => MyComponent), - loading: MyLoadingComponent, - webpack: () => [1], - }); + let ChunkMyComponent = chunk(createLoader(200, () => MyComponent), { + webpack: () => [1] + })(SingleImport); - await Loadable.preloadReady(); + await preloadReady(); - let component = renderer.create(); + let component = renderer.create(); expect(component.toJSON()).toMatchSnapshot(); }); test('many', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(200, () => MyComponent), - loading: MyLoadingComponent, + let LoadableMyComponent = chunk(createLoader(200, () => MyComponent), { webpack: () => [1, 2], - }); + })(SingleImport); - await Loadable.preloadReady(); + await preloadReady(); let component = renderer.create(); @@ -252,13 +493,11 @@ describe('preloadReady', () => { }); test('missing', async () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(200, () => MyComponent), - loading: MyLoadingComponent, + let LoadableMyComponent = chunk(createLoader(200, () => MyComponent), { webpack: () => [1, 42], - }); + })(SingleImport); - await Loadable.preloadReady(); + await preloadReady(); let component = renderer.create(); @@ -266,15 +505,58 @@ describe('preloadReady', () => { }); test('delay with 0', () => { - let LoadableMyComponent = Loadable({ - loader: createLoader(300, () => MyComponent), - loading: MyLoadingComponent, + let LoadableMyComponent = chunk(createLoader(300, () => MyComponent), { delay: 0, timeout: 200, - }); - + })(SingleImport); + let loadingComponent = renderer.create(); expect(loadingComponent.toJSON()).toMatchSnapshot(); // loading }); + + describe('hoist: true', function () { + test('applies statics when pre-loaded on client', async () => { + const jestFn = jest.fn(); + MyComponent.myStatic = jestFn; + let ChunkMyComponent = chunk(createLoader(400, () => MyComponent), { + hoist: true, + webpack: () => [1, 2] + })(); + + await preloadReady(); + + expect(ChunkMyComponent.myStatic).toBe(jestFn); // success + delete MyComponent.myStatic; + }); + + test('throws on client `preloadReady()` when import is invalid', async () => { + let ChunkMyComponent = chunk(createLoader(400, () => { throw new Error('import err'); }), { + hoist: true, + webpack: () => [1, 2] + })(); + + expect.assertions(1); + try { + await preloadReady(); + } catch (e) { + expect(e.message).toEqual('import err'); + } + }); + }); +}); + +test('preloadChunks', async () => { + let LoadableMyComponent = chunk(createLoader(300, () => MyComponent))(SingleImport); + let LoadableMapComponent = chunks({ MyComponent: createLoader(300, () => MyComponent) })(MapImport); + + const loaders = [LoadableMyComponent.getLoader(), LoadableMapComponent.getLoader()]; + + await preloadChunks(loaders); + + let chunkComponent = renderer.create(); + let chunksComponent = renderer.create(); + + expect(chunkComponent.toJSON()).toMatchSnapshot(); + expect(chunksComponent.toJSON()).toMatchSnapshot(); }); diff --git a/example/.babelrc b/example/.babelrc index 5878c9a..7f2a707 100644 --- a/example/.babelrc +++ b/example/.babelrc @@ -6,12 +6,13 @@ "plugins": [ "dynamic-import-node", "transform-class-properties", + "transform-object-rest-spread", "../babel", ["module-resolver", { "alias": { - "react-loadable": "./src/index.js", - "react-loadable/server": "./src/server.js", - "react-loadable/webpack": "./src/webpack.js", + "react-chunk": "./src/index.js", + "react-chunk/Recorder": "./src/Recorder.js", + "react-chunk/webpack": "./src/webpack.js", }, }] ], diff --git a/example/client.js b/example/client.js index 6ac1dd0..24b5514 100644 --- a/example/client.js +++ b/example/client.js @@ -1,10 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import Loadable from 'react-loadable'; +import { preloadReady } from 'react-chunk'; import App from './components/App'; window.main = () => { - Loadable.preloadReady().then(() => { + preloadReady().then(() => { ReactDOM.hydrate(, document.getElementById('app')); + }).catch(err => { + // In a real app, render an error page here + // This could occur when webpack fails to download chunks + console.error(err); }); }; diff --git a/example/components/App.js b/example/components/App.js index fcaf6d6..cf2b517 100644 --- a/example/components/App.js +++ b/example/components/App.js @@ -1,14 +1,11 @@ import React from 'react'; -import Loadable from 'react-loadable'; -import Loading from './Loading'; -import delay from '../utils/delay'; -import path from 'path'; +import { chunk } from 'react-chunk'; +import ChunkRenderer from './ChunkRenderer'; -const LoadableExample = Loadable({ - loader: () => import('./Example'), - loading: Loading, -}); +const AppChunk = chunk(() => import(/* webpackChunkName: "App" */ './Example'), { + delay: 250 +})(ChunkRenderer); export default function App() { - return ; + return ; } diff --git a/example/components/ChunkRenderer.js b/example/components/ChunkRenderer.js new file mode 100644 index 0000000..1031e63 --- /dev/null +++ b/example/components/ChunkRenderer.js @@ -0,0 +1,36 @@ +import React from 'react'; + +export default function ChunkRenderer(props) { + const { + chunk: { + isLoading, + timedOut, + pastDelay, + error, + hasLoaded, + Imported + }, + ...passThroughProps + } = props; + + if (hasLoaded) { + return + } + if (isLoading) { + if (timedOut) { + return
Loader timed out!
; + } + + if (pastDelay) { + return
Loading...
; + } + + return null; + } + + if (error) { + return
Error! Component failed to load
; + } + + return null; +} diff --git a/example/components/ChunksRenderer.js b/example/components/ChunksRenderer.js new file mode 100644 index 0000000..1297007 --- /dev/null +++ b/example/components/ChunksRenderer.js @@ -0,0 +1,48 @@ +import React from 'react'; + +export default function ChunksRenderer(props) { + const { + chunk, + children, + ...passThroughProps + } = props; + + const { + isLoading, + timedOut, + pastDelay, + error, + isLoaded, + imported, + importKeys + } = chunk; + + if (isLoaded) { + if (children) { + return React.cloneElement(children, { ...passThroughProps, chunk }) + } + + return ( + + {importKeys.map((key, idx) => React.createElement(imported[key], { ...passThroughProps, key: idx }))} + + ); + } + if (isLoading) { + if (timedOut) { + return
Loader timed out!
; + } + + if (pastDelay) { + return
Loading...
; + } + + return null; + } + + if (error) { + return
Error! Component failed to load
; + } + + return null; +} diff --git a/example/components/Example.js b/example/components/Example.js index 4c5cc82..18767c9 100644 --- a/example/components/Example.js +++ b/example/components/Example.js @@ -1,19 +1,26 @@ import React from 'react'; -import Loadable from 'react-loadable'; -import Loading from './Loading'; -import delay from '../utils/delay'; -import path from 'path'; +import { chunks } from 'react-chunk'; +import ChunksRenderer from './ChunksRenderer'; +import PreLoadButton from './PreLoadButton'; -const LoadableNested = Loadable({ - loader: () => import('./ExampleNested'), - loading: Loading, -}); +const NestedChunk = chunks({ + Hello: () => import(/* webpackChunkName: "NestedHello" */ './NestedHello'), + World: () => import(/* webpackChunkName: "NestedWorld" */ './NestedWorld'), +})(ChunksRenderer); + +function HelloWorldRenderer({ chunk: { imported: { Hello, World } }, ...props }) { + return
+} export default function Example() { return (
-

Hello from a loadable component

- +

react-chunk demo

+ + + + +
); } diff --git a/example/components/ExampleNested.js b/example/components/ExampleNested.js deleted file mode 100644 index d29d7e8..0000000 --- a/example/components/ExampleNested.js +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -export default function ExampleNested() { - return

Hello from a nested loadable component!

; -} diff --git a/example/components/Loading.js b/example/components/Loading.js deleted file mode 100644 index d4e0bb4..0000000 --- a/example/components/Loading.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -export default function Loading(props) { - if (props.isLoading) { - if (props.timedOut) { - return
Loader timed out!
; - } else if (props.pastDelay) { - return
Loading...
; - } else { - return null; - } - } else if (props.error) { - return
Error! Component failed to load
; - } else { - return null; - } -} diff --git a/example/components/NestedHello.js b/example/components/NestedHello.js new file mode 100644 index 0000000..74d6e0e --- /dev/null +++ b/example/components/NestedHello.js @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function NestedHello() { + return

"Hello" from a chunk component!

; +} diff --git a/example/components/NestedWorld.js b/example/components/NestedWorld.js new file mode 100644 index 0000000..83b29cb --- /dev/null +++ b/example/components/NestedWorld.js @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function NestedWorld() { + return

"World" from a chunk component!

; +} diff --git a/example/components/PreLoadButton.js b/example/components/PreLoadButton.js new file mode 100644 index 0000000..11ce8ba --- /dev/null +++ b/example/components/PreLoadButton.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { chunk, preloadChunks } from 'react-chunk'; +import ChunkRenderer from "./ChunkRenderer"; + +const ContentChunk = chunk(() => import(/* webpackChunkName: "PreLoadedContent" */ './PreLoadedContent'))(ChunkRenderer); + +// NOTE: This is for demo purposes only. +// Pre-loading a single module is no different than using a standard loadable +export default class PreLoadButton extends React.Component { + state = { + isLoaded: false + }; + + downloadChunks() { + if (this.state.isLoaded) { + return; + } + + // Verify webpack only submits a single request + preloadChunks([ + ContentChunk.getLoader(), + ContentChunk.getLoader(), + ContentChunk.getLoader() + ]).then(() => { + console.log('pre-loading modules'); + this.setState({ isLoaded: true }); + }).catch(err => { + // on error, can allow user to retry + console.error(err); + }); + } + + render() { + const { isLoaded } = this.state; + console.log('is content pre-loaded? ' + isLoaded); + return ( +
+ + {isLoaded && } +
+ ); + } +} diff --git a/example/components/PreLoadedContent.js b/example/components/PreLoadedContent.js new file mode 100644 index 0000000..45eacde --- /dev/null +++ b/example/components/PreLoadedContent.js @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function PreLoadedContent() { + return

This content was pre-loaded!

; +} diff --git a/example/server.js b/example/server.js index a29c422..be97475 100644 --- a/example/server.js +++ b/example/server.js @@ -2,22 +2,23 @@ import express from 'express'; import path from 'path'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; -import Loadable from 'react-loadable'; -import { getBundles } from 'react-loadable/webpack' +import { preloadAll } from 'react-chunk'; +import { resolveChunks } from 'react-chunk/webpack' +import ChunkRecorder from 'react-chunk/Recorder' import App from './components/App'; -const stats = require('./dist/react-loadable.json'); +const stats = require('./dist/react-chunk.json'); const app = express(); app.get('/', (req, res) => { - let modules = []; + let renderedChunks = []; let html = ReactDOMServer.renderToString( - modules.push(moduleName)}> + renderedChunks.push(chunkName) }> - + ); - let bundles = getBundles(stats, modules); + let bundles = resolveChunks(stats, renderedChunks); let styles = bundles.filter(bundle => bundle.file.endsWith('.css')); let scripts = bundles.filter(bundle => bundle.file.endsWith('.js')); @@ -48,7 +49,7 @@ app.get('/', (req, res) => { app.use('/dist', express.static(path.join(__dirname, 'dist'))); -Loadable.preloadAll().then(() => { +preloadAll().then(() => { app.listen(3000, () => { console.log('Running on http://localhost:3000/'); }); diff --git a/example/utils/delay.js b/example/utils/delay.js deleted file mode 100644 index 9a60aeb..0000000 --- a/example/utils/delay.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/package.json b/package.json index 3669ccc..601b75c 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { - "name": "react-loadable", - "version": "5.3.1", - "description": "A higher order component for loading components with promises", + "name": "react-chunk", + "version": "1.0.0", + "description": "A higher order component for dynamically importing components, forked from react-loadable.", "main": "lib/index.js", - "author": "James Kyle ", + "author": "adam-26", "license": "MIT", - "repository": "thejameskyle/react-loadable", + "repository": "https://github.com/adam-26/react-chunk", "files": [ "babel.js", "webpack.js", @@ -14,20 +14,24 @@ "scripts": { "test": "jest --coverage", "build": "babel src -d lib", - "start": "yarn build && webpack && babel-node example/server.js", + "prestart": "yarn build && webpack", + "start": "babel-node example/server.js", "prepublish": "yarn build" }, "dependencies": { - "prop-types": "^15.5.0" + "hoist-non-react-statics": "^2.5.0", + "prop-types": "^15.5.0", + "react-display-name": "^0.2.3" }, "devDependencies": { - "babel-cli": "^6.24.1", + "babel-cli": "^6.26.0", "babel-loader": "^7.1.2", "babel-plugin-dynamic-import-node": "^1.1.0", "babel-plugin-module-resolver": "^2.7.1", "babel-plugin-transform-async-to-generator": "^6.24.1", "babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-object-assign": "^6.22.0", + "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-preset-es2015": "^6.24.1", "babel-preset-react": "^6.24.1", "express": "^4.16.1", diff --git a/src/Recorder.js b/src/Recorder.js new file mode 100644 index 0000000..39937aa --- /dev/null +++ b/src/Recorder.js @@ -0,0 +1,29 @@ +// @flow +import React from 'react'; +import PropTypes from 'prop-types'; + +class ChunkRecorder extends React.Component { + static propTypes = { + addChunk: PropTypes.func.isRequired, + }; + + static childContextTypes = { + chunks: PropTypes.shape({ + addChunk: PropTypes.func, + }), + }; + + getChildContext() { + return { + chunks: { + addChunk: this.props.addChunk, + }, + }; + } + + render() { + return React.Children.only(this.props.children); + } +} + +module.exports = ChunkRecorder; diff --git a/src/babel.js b/src/babel.js index 047035f..8187f13 100644 --- a/src/babel.js +++ b/src/babel.js @@ -1,91 +1,108 @@ +const PKG_NAME = 'react-chunk'; + export default function({ types: t, template }) { return { visitor: { ImportDeclaration(path) { - let source = path.node.source.value; - if (source !== 'react-loadable') return; - - let defaultSpecifier = path.get('specifiers').find(specifier => { - return specifier.isImportDefaultSpecifier(); - }); - - if (!defaultSpecifier) return; - - let bindingName = defaultSpecifier.node.local.name; - let binding = path.scope.getBinding(bindingName); + const source = path.node.source.value; + if (source !== PKG_NAME) return; - binding.referencePaths.forEach(refPath => { - let callExpression = refPath.parentPath; - - if ( - callExpression.isMemberExpression() && - callExpression.node.computed === false && - callExpression.get('property').isIdentifier({ name: 'Map' }) - ) { - callExpression = callExpression.parentPath; + const importSpecifiers = path.get('specifiers').filter(specifier => { + if (specifier.isImportSpecifier()) { + return ['chunk', 'chunks'].indexOf(specifier.node.imported.name) !== -1; } - if (!callExpression.isCallExpression()) return; + return false; + }); - let args = callExpression.get('arguments'); - if (args.length !== 1) throw callExpression.error; + if (!importSpecifiers.length) { + return; + } - let options = args[0]; - if (!options.isObjectExpression()) return; + importSpecifiers.forEach(importSpecifier => { + const bindingName = importSpecifier.node.local.name; + const binding = path.scope.getBinding(bindingName); - let properties = options.get('properties'); - let propertiesMap = {}; + binding.referencePaths.forEach(refPath => { + const callExpression = refPath.parentPath; + if (!callExpression.isCallExpression()) { + return; + } - properties.forEach(property => { - let key = property.get('key'); - propertiesMap[key.node.name] = property; - }); + const writableChunkArgs = callExpression.node.arguments; + if (writableChunkArgs.length === 0) { + throw callExpression.error; // missing import + } - if (propertiesMap.webpack) { - return; - } + const importStatement = callExpression.get('arguments')[0]; + + let userOptions; + const propertiesMap = {}; + if (writableChunkArgs.length > 1) { + userOptions = callExpression.get('arguments')[1]; + if (t.isObjectExpression(userOptions)) { + userOptions.get('properties').forEach(property => { + const key = property.get('key'); + propertiesMap[key.node.name] = property; + }); + } + } + else { + userOptions = t.objectExpression([]); + writableChunkArgs.push(userOptions); + } + + // webpack options have been manually applied + if (propertiesMap.webpack) { + return; + } - let loaderMethod = propertiesMap.loader.get('value'); - let dynamicImports = []; + // identify all import() statements + const dynamicImports = []; + importStatement.traverse({ + Import(path) { + dynamicImports.push(path.parentPath); + } + }); - loaderMethod.traverse({ - Import(path) { - dynamicImports.push(path.parentPath); + if (!dynamicImports.length) { + return; } - }); - if (!dynamicImports.length) return; + const generatedArgs = []; + generatedArgs.push( // add the prop + t.objectProperty( + t.identifier('webpack'), + t.arrowFunctionExpression( + [], + t.arrayExpression( + dynamicImports.map(dynamicImport => { + return t.callExpression( + t.memberExpression( + t.identifier('require'), + t.identifier('resolveWeak'), + ), + [dynamicImport.get('arguments')[0].node], + ) + }) + ) + ) + ) + ); - propertiesMap.loader.insertAfter( - t.objectProperty( - t.identifier('webpack'), - t.arrowFunctionExpression( - [], + generatedArgs.push( // add the prop + t.objectProperty( + t.identifier('modules'), t.arrayExpression( dynamicImports.map(dynamicImport => { - return t.callExpression( - t.memberExpression( - t.identifier('require'), - t.identifier('resolveWeak'), - ), - [dynamicImport.get('arguments')[0].node], - ) + return dynamicImport.get('arguments')[0].node; }) ) ) - ) - ); + ); - propertiesMap.loader.insertAfter( - t.objectProperty( - t.identifier('modules'), - t.arrayExpression( - dynamicImports.map(dynamicImport => { - return dynamicImport.get('arguments')[0].node; - }) - ) - ) - ); + writableChunkArgs.push(t.objectExpression(generatedArgs)); + }); }); } } diff --git a/src/index.js b/src/index.js index 95827e9..6e925f0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,12 @@ 'use strict'; const React = require('react'); const PropTypes = require('prop-types'); +const hoistNonReactStatics = require('hoist-non-react-statics'); +const getDisplayName = require('react-display-name').default; const ALL_INITIALIZERS = []; const READY_INITIALIZERS = []; +const TIMEOUT_ERR = '_t'; function isWebpackReady(getModuleIds) { if (typeof __webpack_modules__ !== 'object') { @@ -18,13 +21,63 @@ function isWebpackReady(getModuleIds) { }); } -function load(loader) { - let promise = loader(); +function retryLoader(resolve, reject, fn, retryOpts) { + if (retryOpts.hasResolved) { + return; + } - let state = { + const invokeRetry = (err) => { + const backOff = retryOpts.backOff; + if (backOff.length) { + const wait = backOff.shift(); + setTimeout(() => retryLoader(resolve, reject, fn, retryOpts), wait); + } + else if (err && !retryOpts.hasResolved) { + retryOpts.hasResolved = true; + reject(err); + } + }; + + let _timeout; + if (retryOpts.importTimeoutMs > 0) { + _timeout = setTimeout( + () => invokeRetry(retryOpts.throwOnImportError ? new Error(TIMEOUT_ERR) : null), + retryOpts.importTimeoutMs); + } + + fn() + .then(res => { + clearTimeout(_timeout); + + if (!retryOpts.hasResolved) { + retryOpts.hasResolved = true; + resolve(res) + } + }) + .catch((err) => { + clearTimeout(_timeout); + invokeRetry(err); + }); +} + +function hasLoaded(state) { + return !state.loading && !state.error && !!state.loaded; +} + +function load(loader, options) { + const promise = new Promise((resolve, reject) => { + retryLoader(resolve, reject, loader, { + backOff: options.retryBackOff.slice(), + importTimeoutMs: options.importTimeoutMs, + throwOnImportError: options.throwOnImportError, + hasResolved: false + }); + }); + + const state = { loading: true, loaded: null, - error: null + error: null, }; state.promise = promise.then(loaded => { @@ -40,7 +93,7 @@ function load(loader) { return state; } -function loadMap(obj) { +function loadMap(obj, options) { let state = { loading: false, loaded: {}, @@ -51,7 +104,7 @@ function loadMap(obj) { try { Object.keys(obj).forEach(key => { - let result = load(obj[key]); + let result = load(obj[key], options); if (!result.loading) { state.loaded[key] = result.loaded; @@ -87,211 +140,347 @@ function resolve(obj) { return obj && obj.__esModule ? obj.default : obj; } -function render(loaded, props) { - return React.createElement(resolve(loaded), props); -} - -function createLoadableComponent(loadFn, options) { - if (!options.loading) { - throw new Error('react-loadable requires a `loading` component') - } - +function createChunkComponent(loadFn, options) { let opts = Object.assign({ + displayName: null, loader: null, - loading: null, + hoist: false, + resolveDefaultImport: (imported /*, importKey */) => resolve(imported), + retryBackOff: [], delay: 200, timeout: null, - render: render, webpack: null, - modules: null, + modules: [], }, options); let res = null; + let importTimeoutMs = typeof opts.timeout === 'number' ? opts.timeout : 0; - function init() { - if (!res) { - res = loadFn(opts.loader); - } - return res.promise; + // Adjust the UI timeout to include the retry backOff options + if (opts.retryBackOff.length && typeof opts.timeout === 'number') { + opts.timeout = opts.retryBackOff.reduce((total, ms) => { + return total + ms; + }, opts.timeout * opts.retryBackOff.length); } - ALL_INITIALIZERS.push(init); - - if (typeof opts.webpack === 'function') { - READY_INITIALIZERS.push(() => { - if (isWebpackReady(opts.webpack)) { - return init(); + return (WrappedComponent) => { + if (!opts.singleImport && typeof WrappedComponent === 'undefined') { + throw new Error('`chunks({..})([missing])` requires a component to wrap.'); } - }); - } - - return class LoadableComponent extends React.Component { - constructor(props) { - super(props); - init(); - - this.state = { - error: res.error, - pastDelay: false, - timedOut: false, - loading: res.loading, - loaded: res.loaded - }; - } - static contextTypes = { - loadable: PropTypes.shape({ - report: PropTypes.func.isRequired, - }), - }; + class ChunkComponent extends React.Component { + constructor(props) { + super(props); + init(false); - static preload() { - return init(); - } + this.state = { + error: res.error, + pastDelay: false, + timedOut: false, + loading: res.loading, + loaded: res.loaded + }; + } - componentWillMount() { - this._mounted = true; + static propTypes = { + chunks: PropTypes.shape({ + addChunk: PropTypes.func.isRequired, + }) + }; - if (this.context.loadable && Array.isArray(opts.modules)) { - opts.modules.forEach(moduleName => { - this.context.loadable.report(moduleName); - }); + static preload() { + return init(true); } - if (!res.loading) { - return; + static getLoader() { + return init; } - if (typeof opts.delay === 'number') { - if (opts.delay === 0) { - this.setState({ pastDelay: true }); - } else { - this._delay = setTimeout(() => { + _loadChunks() { + if (!res.loading) { + return; + } + + // clear timeouts - in case 'retry' is invoked before loading is complete + this._clearTimeouts(); + + if (typeof opts.delay === 'number') { + if (opts.delay === 0) { this.setState({ pastDelay: true }); - }, opts.delay); + } else { + this._delay = setTimeout(() => { + this.setState({ pastDelay: true }); + }, opts.delay); + } + } + + // This approach doesn't provide ms specific feedback, but implementation is really easy + // - an alternative is to subscribe to: res.onTimeout(() => setState(...)) + // - if more accurate feedback is required, this can be implemented (w/'unsubscribe' on _clearTimeouts) + if (typeof opts.timeout === 'number') { + this._timeout = setTimeout(() => { + this.setState({ timedOut: true }); + }, opts.timeout); } + + let update = () => { + hoistStatics(); + + if (!this._mounted) { + return; + } + + this.setState({ + error: res.error, + loaded: res.loaded, + loading: res.loading + }); + + this._clearTimeouts(); + }; + + res.promise.then(() => { + update(); + }).catch(err => { + update(); + }); } - if (typeof opts.timeout === 'number') { - this._timeout = setTimeout(() => { - this.setState({ timedOut: true }); - }, opts.timeout); + _clearTimeouts() { + clearTimeout(this._delay); + clearTimeout(this._timeout); } - let update = () => { - if (!this._mounted) { + retry() { + if (hasLoaded(this.state)) { return; } + // reset state for retry + res = null; this.setState({ - error: res.error, - loaded: res.loaded, - loading: res.loading + error: null, + loading: true, + loaded: {}, + pastDelay: false, + timedOut: false, }); - this._clearTimeouts(); - }; + // attempt to load the chunk(s) again + const promise = init(false); // don't throw on err - this component can not support hoist (or logically, it'd never get here) + this._loadChunks(); // update this components state + return promise; + } - res.promise.then(() => { - update(); - }).catch(err => { - update(); - }); - } + componentWillMount() { + this._mounted = true; - componentWillUnmount() { - this._mounted = false; - this._clearTimeouts(); - } + if (this.props.chunks && Array.isArray(opts.modules)) { + opts.modules.forEach(moduleName => { + this.props.chunks.addChunk(moduleName); + }); + } - _clearTimeouts() { - clearTimeout(this._delay); - clearTimeout(this._timeout); - } + this._loadChunks(); + } - render() { - if (this.state.loading || this.state.error) { - return React.createElement(opts.loading, { + componentWillUnmount() { + this._mounted = false; + this._clearTimeouts(); + } + + render() { + const { chunks, ...passThroughProps } = this.props; + const importState = { isLoading: this.state.loading, + hasLoaded: hasLoaded(this.state), pastDelay: this.state.pastDelay, timedOut: this.state.timedOut, - error: this.state.error + error: this.state.error, + loaded: this.state.loaded, + retry: () => this.retry() // binds 'this' + }; + + if (opts.singleImport) { + if (typeof WrappedComponent === 'undefined') { + // no wrapped component + if (importState.hasLoaded) { + return React.createElement(opts.resolveDefaultImport(this.state.loaded), passThroughProps); + } + + return null; + } + + const componentProps = Object.assign({}, passThroughProps, { + chunk: { + ...importState, + importKeys: [], + Imported: importState.hasLoaded ? opts.resolveDefaultImport(importState.loaded) : null + } + }); + + return React.createElement(WrappedComponent, componentProps); + } + + let componentProps = Object.assign({}, passThroughProps, { + chunk: { + ...importState, + importKeys: importState.hasLoaded ? Object.keys(this.state.loaded) : [], + imported: {} + } }); - } else if (this.state.loaded) { - return opts.render(this.state.loaded, this.props); - } else { - return null; + + if (importState.hasLoaded) { + componentProps.chunk.imported = Object.keys(this.state.loaded).reduce((acc, importKey) => { + acc[importKey] = opts.resolveDefaultImport(this.state.loaded[importKey], importKey); + return acc; + }, componentProps.chunk.imported); + } + + return React.createElement(WrappedComponent, componentProps); } } - }; -} -function Loadable(opts) { - return createLoadableComponent(load, opts); -} + // Apply chunks context to the chunk component + const ChunkHOC = withChunks(ChunkComponent); -function LoadableMap(opts) { - if (typeof opts.render !== 'function') { - throw new Error('LoadableMap requires a `render(loaded, props)` function'); - } + const hasWrappedComponent = !(typeof WrappedComponent === 'undefined' || WrappedComponent === null); + const wrappedComponentName = opts.displayName || (hasWrappedComponent ? getDisplayName(WrappedComponent) : ''); + ChunkHOC.displayName = opts.singleImport ? `chunk(${wrappedComponentName})` : `chunks(${wrappedComponentName})`; - return createLoadableComponent(loadMap, opts); -} + let _hoisted = false; + function hoistStatics() { + if (_hoisted || !opts.hoist) { + return; + } -Loadable.Map = LoadableMap; + // Only hoist the static methods once + if (!res.error && res.loaded && !_hoisted) { + // Hoist is only supported by 'chunk' + hoistNonReactStatics(ChunkHOC, opts.resolveDefaultImport(res.loaded)); + _hoisted = true; + } + } -class Capture extends React.Component { - static propTypes = { - report: PropTypes.func.isRequired, - }; + function init(throwOnImportError) { + if (!res) { + res = loadFn(opts.loader, { + retryBackOff: Array.isArray(opts.retryBackOff) ? opts.retryBackOff : [], + importTimeoutMs: importTimeoutMs, + throwOnImportError: throwOnImportError + }); + } - static childContextTypes = { - loadable: PropTypes.shape({ - report: PropTypes.func.isRequired, - }).isRequired, - }; + if (opts.hoist) { + res.promise = res.promise + .then(() => { hoistStatics(); }) + .catch(err => { + if (throwOnImportError === true) { + // When pre-loading, any loader errors will be thrown immediately (ie: hoist, timeout options) + // - hoisting implies use of static methods, which need to be available prior to rendering. + throw err; + } + }); + } - getChildContext() { - return { - loadable: { - report: this.props.report, - }, - }; - } + return res.promise; + } + + ALL_INITIALIZERS.push(init); - render() { - return React.Children.only(this.props.children); + if (typeof opts.webpack === 'function') { + READY_INITIALIZERS.push((throwOnImportError) => { + if (isWebpackReady(opts.webpack)) { + return init(throwOnImportError); + } + }); + } + + // Hoist any statics on the wrapped component + return hasWrappedComponent ? hoistNonReactStatics(ChunkHOC, WrappedComponent) : ChunkHOC; } } -Loadable.Capture = Capture; +function chunk(dynamicImport, opts = {}, webpackOpts = {}) { + if (typeof dynamicImport !== 'function') { + throw new Error('`chunk()` requires an import function.'); + } -function flushInitializers(initializers) { - let promises = []; + return createChunkComponent(load, { ...webpackOpts, ...opts, loader: dynamicImport, singleImport: true }); +} + +function chunks(dynamicImport, opts = {}, webpackOpts = {}) { + if (typeof dynamicImport !== 'object' || Array.isArray(dynamicImport) || dynamicImport === null) { + throw new Error('`chunks()` requires a map of import functions.'); + } - while (initializers.length) { - let init = initializers.pop(); - promises.push(init()); + if (typeof opts.hoist !== 'undefined') { + throw new Error('`chunks()` does not support the "hoist" option.'); } - return Promise.all(promises).then(() => { - if (initializers.length) { - return flushInitializers(initializers); + return createChunkComponent(loadMap, {...webpackOpts, ...opts, loader: dynamicImport, singleImport: false }); +} + +function flushInitializers(initializers) { + let promises = []; + + while (initializers.length) { + let init = initializers.pop(); + promises.push(init(true)); } - }); + + return Promise.all(promises).then(() => { + if (initializers.length) { + return flushInitializers(initializers); + } + }); } -Loadable.preloadAll = () => { - return new Promise((resolve, reject) => { - flushInitializers(ALL_INITIALIZERS).then(resolve, reject); - }); -}; +function preloadChunks(loaders) { + return new Promise((resolve, reject) => { + return flushInitializers(loaders).then(resolve, reject); + }); +} -Loadable.preloadReady = () => { - return new Promise((resolve, reject) => { - // We always will resolve, errors should be handled within loading UIs. - flushInitializers(READY_INITIALIZERS).then(resolve, resolve); - }); -}; +function preloadAll() { + return preloadChunks(ALL_INITIALIZERS); +} + +function preloadReady() { + return preloadChunks(READY_INITIALIZERS); +} + +function noop() {} + +// HOC to access the chunks context +function withChunks(Component) { + class ChunkReporter extends React.Component { + + static contextTypes = { + chunks: PropTypes.shape({ + addChunk: PropTypes.func.isRequired, + }) + }; + + render() { + const { chunks } = this.context; + return React.createElement(Component, { + ...this.props, + chunks: { + addChunk: (chunks && chunks.addChunk) || noop + } + }); + } + } + + return hoistNonReactStatics(ChunkReporter, Component); +} -module.exports = Loadable; +exports.chunk = chunk; +exports.chunks = chunks; +exports.preloadReady = preloadReady; +exports.preloadAll = preloadAll; +exports.preloadChunks = preloadChunks; +exports.resolve = resolve; +exports.withChunks = withChunks; +exports.TIMEOUT_ERR = TIMEOUT_ERR; diff --git a/src/webpack.js b/src/webpack.js index 8a43dd7..7ee0cfd 100644 --- a/src/webpack.js +++ b/src/webpack.js @@ -3,42 +3,57 @@ const fs = require('fs'); const path = require('path'); const url = require('url'); -function buildManifest(compiler, compilation) { - let context = compiler.options.context; - let manifest = {}; +function buildManifest(compiler, compilation, ignoreChunkNames) { + const context = compiler.options.context; + const manifest = {}; compilation.chunks.forEach(chunk => { - chunk.files.forEach(file => { - chunk.forEachModule(module => { - let id = module.id; - let name = typeof module.libIdent === 'function' ? module.libIdent({ context }) : null; - let publicPath = url.resolve(compilation.outputOptions.publicPath || '', file); - - let currentModule = module; - if (module.constructor.name === 'ConcatenatedModule') { - currentModule = module.rootModule; - } - if (!manifest[currentModule.rawRequest]) { - manifest[currentModule.rawRequest] = []; - } + // Determine if the chunk should be ignored + const chunkName = typeof chunk.name === 'undefined' ? 'undefined' : chunk.name === null ? 'null' : chunk.name; + const ignoreChunk = ignoreChunkNames.length === 0 ? false : ignoreChunkNames.some(chunkNameCondition => { + if (chunkNameCondition instanceof RegExp) { + chunkNameCondition.lastIndex = 0; // reset in-case its a global regexp + return chunkNameCondition.test(chunkName); + } - manifest[currentModule.rawRequest].push({ id, name, file, publicPath }); - }); + return chunkNameCondition === chunkName; }); + + if (!ignoreChunk) { + chunk.files.forEach(file => { + chunk.forEachModule(module => { + const id = module.id; + const name = typeof module.libIdent === 'function' ? module.libIdent({ context }) : null; + const publicPath = url.resolve(compilation.outputOptions.publicPath || '', file); + + let currentModule = module; + if (module.constructor.name === 'ConcatenatedModule') { + currentModule = module.rootModule; + } + if (!manifest[currentModule.rawRequest]) { + manifest[currentModule.rawRequest] = []; + } + + manifest[currentModule.rawRequest].push({ id, name, file, publicPath }); + }); + }); + } }); return manifest; } -class ReactLoadablePlugin { +class ReactChunkPlugin { constructor(opts = {}) { this.filename = opts.filename; + const ignoreChunkNames = opts.ignoreChunkNames || []; + this.ignoreChunkNames = Array.isArray(ignoreChunkNames) ? ignoreChunkNames : [ignoreChunkNames]; } apply(compiler) { compiler.plugin('emit', (compilation, callback) => { - const manifest = buildManifest(compiler, compilation); - var json = JSON.stringify(manifest, null, 2); + const manifest = buildManifest(compiler, compilation, this.ignoreChunkNames); + const json = JSON.stringify(manifest, null, 2); const outputDirectory = path.dirname(this.filename); try { fs.mkdirSync(outputDirectory); @@ -53,11 +68,20 @@ class ReactLoadablePlugin { } } -function getBundles(manifest, moduleIds) { - return moduleIds.reduce((bundles, moduleId) => { - return bundles.concat(manifest[moduleId]); +function resolveChunks(manifest, chunkIds) { + const uniqueIds = chunkIds + .reduce((uniqueIds, chunkId) => { + if (uniqueIds.indexOf(chunkId) === -1) { + uniqueIds.push(chunkId); + } + + return uniqueIds; + }, []); + + return uniqueIds.reduce((bundles, chunkId) => { + return bundles.concat(manifest[chunkId]); }, []); } -exports.ReactLoadablePlugin = ReactLoadablePlugin; -exports.getBundles = getBundles; +exports.ReactChunkPlugin = ReactChunkPlugin; +exports.resolveChunks = resolveChunks; diff --git a/webpack.config.js b/webpack.config.js index f462e46..7c69852 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,5 @@ const path = require('path'); -const { ReactLoadablePlugin } = require('./webpack'); +const { ReactChunkPlugin } = require('./webpack'); module.exports = { entry: { @@ -27,6 +27,7 @@ module.exports = { plugins: [ 'syntax-dynamic-import', 'transform-class-properties', + 'transform-object-rest-spread', 'transform-object-assign', require.resolve('./babel'), ], @@ -38,12 +39,13 @@ module.exports = { devtool: 'inline-source-map', resolve: { alias: { - 'react-loadable': path.resolve(__dirname, 'src'), + 'react-chunk': path.resolve(__dirname, 'src'), }, }, plugins: [ - new ReactLoadablePlugin({ - filename: path.resolve(__dirname, 'example', 'dist', 'react-loadable.json'), + new ReactChunkPlugin({ + filename: path.resolve(__dirname, 'example', 'dist', 'react-chunk.json'), + ignoreChunkNames: ['main'] }), ] };