├── .eslintignore ├── __fixtures__ ├── es5.js ├── es6.js ├── component2.js ├── component3.js ├── component.es5.js └── component.js ├── .github └── CODEOWNERS ├── .gitignore ├── react-router-quote.png ├── tests-screenshot-1.png ├── tests-screenshot-2.png ├── universal-graphic.png ├── .babelrc ├── flow-typed └── webpack.js ├── src ├── context.js ├── requireById │ └── index.js ├── report-chunks.js ├── helpers.js ├── utils.js ├── flowTypes.js ├── requireUniversalModule.js └── index.js ├── server.d.ts ├── .editorconfig ├── server.js ├── .npmignore ├── .flowconfig ├── .codeclimate.yml ├── wallaby.js ├── .travis.yml ├── LICENSE ├── __tests__ ├── utils.js ├── __snapshots__ │ └── index.js.snap ├── requireUniversalModule.js └── index.js ├── __test-helpers__ ├── createApp.js └── index.js ├── package.json ├── .eslintrc.js ├── index.d.ts └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | flow-typed 3 | node_modules 4 | docs 5 | -------------------------------------------------------------------------------- /__fixtures__/es5.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | foo: 'bar-es5' 3 | } 4 | -------------------------------------------------------------------------------- /__fixtures__/es6.js: -------------------------------------------------------------------------------- 1 | export default 'hello' 2 | export const foo = 'bar' 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # babel-plugin-universal-import maintainers 2 | * @ScriptedAlchemy 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules 4 | *.log 5 | .idea 6 | .DS_Store 7 | .history 8 | -------------------------------------------------------------------------------- /__fixtures__/component2.js: -------------------------------------------------------------------------------- 1 | const React = require('react') 2 | 3 | export default () =>
fixture2
4 | -------------------------------------------------------------------------------- /__fixtures__/component3.js: -------------------------------------------------------------------------------- 1 | const React = require('react') 2 | 3 | export default () =>
fixture3
4 | -------------------------------------------------------------------------------- /react-router-quote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/faceyspacey/react-universal-component/HEAD/react-router-quote.png -------------------------------------------------------------------------------- /tests-screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/faceyspacey/react-universal-component/HEAD/tests-screenshot-1.png -------------------------------------------------------------------------------- /tests-screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/faceyspacey/react-universal-component/HEAD/tests-screenshot-2.png -------------------------------------------------------------------------------- /universal-graphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/faceyspacey/react-universal-component/HEAD/universal-graphic.png -------------------------------------------------------------------------------- /__fixtures__/component.es5.js: -------------------------------------------------------------------------------- 1 | const React = require('react') 2 | 3 | module.exports = () =>
fixture-ES5
4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-0"], 3 | "plugins": ["universal-import", "transform-flow-strip-types"] 4 | } 5 | -------------------------------------------------------------------------------- /flow-typed/webpack.js: -------------------------------------------------------------------------------- 1 | declare function __webpack_require__(pathOrId: string | number): any 2 | declare var __webpack_modules__: {} 3 | -------------------------------------------------------------------------------- /__fixtures__/component.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default () =>
fixture1
4 | 5 | export const foo = 'bar' 6 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ReportContext = React.createContext({ report: () => {} }) 4 | 5 | export default ReportContext 6 | -------------------------------------------------------------------------------- /server.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-universal-component/server' { 2 | const clearChunks: () => void; 3 | const flushChunkNames: () => string[]; 4 | 5 | export { clearChunks, flushChunkNames }; 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | [*] 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | [*.md] 10 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /src/requireById/index.js: -------------------------------------------------------------------------------- 1 | import { isWebpack } from '../utils' 2 | 3 | const requireById = id => { 4 | if (!isWebpack() && typeof id === 'string') { 5 | return module.require(`${id}`) 6 | } 7 | 8 | return __webpack_require__(`${id}`) 9 | } 10 | 11 | export default requireById 12 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | flushModuleIds: require('./dist/requireUniversalModule').flushModuleIds, 3 | flushChunkNames: require('./dist/requireUniversalModule').flushChunkNames, 4 | clearChunks: require('./dist/requireUniversalModule').clearChunks, 5 | ReportChunks: require('./dist/report-chunks').default 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | __test-helpers__ 3 | __tests__ 4 | __fixtures__ 5 | docs 6 | flow-typed 7 | src 8 | *.log 9 | 10 | .babelrc 11 | .codeclimate.yml 12 | .editorconfig 13 | .eslintrc.js 14 | .snyk 15 | .travis.yml 16 | wallaby.js 17 | webpack.config.js 18 | .eslintignore 19 | .flowconfig 20 | *.png 21 | yarn.lock 22 | .idea 23 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/config-chain/.* 3 | .*/node_modules/npmconf/.* 4 | .*/node_modules/chalk/.* 5 | 6 | [include] 7 | 8 | [libs] 9 | 10 | [options] 11 | 12 | esproposal.class_static_fields=enable 13 | esproposal.class_instance_fields=enable 14 | 15 | module.file_ext=.js 16 | module.file_ext=.json 17 | module.system=haste 18 | 19 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe 20 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue 21 | suppress_comment=\\(.\\|\n\\)*\\$FlowIgnore 22 | suppress_comment=\\(.\\|\n\\)*\\$FlowGlobal 23 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | JavaScript: true 3 | 4 | engines: 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - javascript: 10 | fixme: 11 | enabled: true 12 | eslint: 13 | enabled: true 14 | config: 15 | config: .eslintrc.js 16 | checks: 17 | import/no-unresolved: 18 | enabled: false 19 | import/extensions: 20 | enabled: false 21 | 22 | ratings: 23 | paths: 24 | - "src/**" 25 | 26 | exclude_paths: 27 | - "docs/" 28 | - "dist/" 29 | - "flow-typed/" 30 | - "node_modules/" 31 | - ".vscode/" 32 | - ".eslintrc.js" 33 | - "**/*.snap" 34 | -------------------------------------------------------------------------------- /src/report-chunks.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react' 4 | import PropTypes from 'prop-types' 5 | import ReportContext from './context' 6 | 7 | type Props = { 8 | report: Function, 9 | children: Object 10 | } 11 | 12 | export default class ReportChunks extends React.Component { 13 | static propTypes = { 14 | report: PropTypes.func.isRequired 15 | } 16 | 17 | constructor(props: Props) { 18 | super(props) 19 | this.state = { 20 | report: props.report 21 | } 22 | } 23 | 24 | render() { 25 | return ( 26 | 27 | {this.props.children} 28 | 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = wallaby => { 2 | process.env.NODE_ENV = 'test' 3 | 4 | return { 5 | files: [ 6 | { pattern: 'src/**/*.js', load: false }, 7 | { pattern: 'package.json', load: false }, 8 | { pattern: '__tests__/**/*.snap', load: false }, 9 | { pattern: '__fixtures__/**/*.js', load: false }, 10 | { pattern: '__test-helpers__/**/*.js', load: false }, 11 | { pattern: 'server.js', load: false } 12 | ], 13 | 14 | filesWithNoCoverageCalculated: [ 15 | '__fixtures__/**/*.js', 16 | '__test-helpers__/**/*.js', 17 | 'server.js' 18 | ], 19 | 20 | tests: ['__tests__/**/*.js'], 21 | 22 | env: { 23 | type: 'node', 24 | runner: 'node' 25 | }, 26 | 27 | testFramework: 'jest', 28 | compilers: { 29 | '**/*.js': wallaby.compilers.babel({ babelrc: true }) 30 | }, 31 | debug: false 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | cache: yarn 5 | install: yarn --ignore-engines # ignore engines to test node 6, otherwise it fails on engine check 6 | jobs: 7 | include: 8 | - stage: Build 9 | name: Travis Status 10 | script: npx travis-github-status 11 | name: Linting 12 | script: npm run lint 13 | name: Flow 14 | script: npm run flow 15 | name: Snyk 16 | script: snyk 17 | notifications: 18 | email: false 19 | webhooks: 20 | urls: 21 | - https://webhooks.gitter.im/e/5156be73e058008e1ed2 22 | on_success: always # options: [always|never|change] default: always 23 | on_failure: always # options: [always|never|change] default: always 24 | on_start: never # options: [always|never|change] default: always 25 | after_success: 26 | - npm run semantic-release 27 | branches: 28 | except: 29 | - /^v\d+\.\d+\.\d+$/ 30 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import hoist from 'hoist-non-react-statics' 2 | import UniversalComponent from './index' 3 | 4 | export const __update = ( 5 | props, 6 | state, 7 | isInitialized, 8 | isMount = false, 9 | isSync = false, 10 | isServer = false 11 | ) => { 12 | if (!isInitialized) return state 13 | if (!state.error) { 14 | state.error = null 15 | } 16 | return __handleAfter(props, state, isMount, isSync, isServer) 17 | } 18 | 19 | /* eslint class-methods-use-this: ["error", { "exceptMethods": ["__handleAfter"] }] */ 20 | export const __handleAfter = (props, state, isMount, isSync, isServer) => { 21 | const { mod, error } = state 22 | 23 | if (mod && !error) { 24 | hoist(UniversalComponent, mod, { 25 | preload: true, 26 | preloadWeak: true 27 | }) 28 | 29 | if (props.onAfter) { 30 | const { onAfter } = props 31 | const info = { isMount, isSync, isServer } 32 | onAfter(info, mod) 33 | } 34 | } 35 | else if (error && props.onError) { 36 | props.onError(error) 37 | } 38 | 39 | return state 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | COPYRIGHT (c) 2017-present James Gillmore 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /__tests__/utils.js: -------------------------------------------------------------------------------- 1 | import { createPath } from '../__test-helpers__' 2 | 3 | import { tryRequire, resolveExport, findExport } from '../src/utils' 4 | 5 | import requireById from '../src/requireById' 6 | 7 | test('tryRequire: requires module using key export finder + calls onLoad with module', () => { 8 | const moduleEs6 = createPath('es6') 9 | const expectedModule = require(moduleEs6) 10 | 11 | // babel 12 | let mod = tryRequire(moduleEs6) 13 | expect(mod).toEqual(expectedModule) 14 | 15 | // webpack 16 | global.__webpack_require__ = path => __webpack_modules__[path] 17 | global.__webpack_modules__ = { 18 | [moduleEs6]: expectedModule 19 | } 20 | 21 | mod = tryRequire(moduleEs6) 22 | expect(mod).toEqual(expectedModule) 23 | 24 | delete global.__webpack_require__ 25 | delete global.__webpack_modules__ 26 | 27 | // module not found 28 | mod = tryRequire('/foo') 29 | expect(mod).toEqual(null) 30 | }) 31 | 32 | test('requireById: requires module for babel or webpack depending on environment', () => { 33 | const moduleEs6 = createPath('es6') 34 | const expectedModule = require(moduleEs6) 35 | 36 | // babel 37 | let mod = requireById(moduleEs6) 38 | expect(mod).toEqual(expectedModule) 39 | 40 | // webpack 41 | global.__webpack_require__ = path => __webpack_modules__[path] 42 | global.__webpack_modules__ = { 43 | [moduleEs6]: expectedModule 44 | } 45 | 46 | mod = requireById(moduleEs6) 47 | expect(mod).toEqual(expectedModule) 48 | 49 | delete global.__webpack_require__ 50 | delete global.__webpack_modules__ 51 | 52 | // module not found 53 | expect(() => requireById('/foo')).toThrow() 54 | }) 55 | 56 | test('resolveExport: finds export and calls onLoad', () => { 57 | const onLoad = jest.fn() 58 | const mod = { foo: 'bar' } 59 | const props = { baz: 123 } 60 | 61 | const exp = resolveExport(mod, 'foo', onLoad, undefined, props) 62 | expect(exp).toEqual('bar') 63 | 64 | const info = { isServer: false, isSync: false } 65 | expect(onLoad).toBeCalledWith(mod, info, props) 66 | // todo: test caching 67 | }) 68 | 69 | test('findExport: finds export in module via key string, function or returns module if key === null', () => { 70 | const mod = { foo: 'bar' } 71 | 72 | // key as string 73 | let exp = findExport(mod, 'foo') 74 | expect(exp).toEqual('bar') 75 | 76 | // key as function 77 | exp = findExport(mod, mod => mod.foo) 78 | expect(exp).toEqual('bar') 79 | 80 | // key as null 81 | exp = findExport(mod, null) 82 | expect(exp).toEqual(mod) 83 | 84 | // default: no key 85 | exp = findExport({ __esModule: true, default: 'baz' }) 86 | expect(exp).toEqual('baz') 87 | }) 88 | -------------------------------------------------------------------------------- /__test-helpers__/createApp.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import React from 'react' 3 | import { 4 | createComponent, 5 | createBablePluginComponent, 6 | createDynamicBablePluginComponent 7 | } from './index' 8 | import universal from '../src' 9 | 10 | export const createPath = name => path.join(__dirname, '../__fixtures__', name) 11 | 12 | export const createApp = isWebpack => { 13 | const importAsync = createComponent(40) 14 | const create = name => 15 | universal(importAsync, { 16 | path: createPath(name), 17 | resolve: isWebpack && createPath(name), 18 | chunkName: name 19 | }) 20 | 21 | const Component1 = create('component') 22 | const Component2 = create('component2') 23 | const Component3 = create('component3') 24 | 25 | return props => ( 26 |
27 | {props.one ? : null} 28 | {props.two ? : null} 29 | {props.three ? : null} 30 |
31 | ) 32 | } 33 | 34 | export const createDynamicApp = isWebpack => { 35 | const importAsync = createComponent(40) 36 | const Component = universal(importAsync, { 37 | path: ({ page }) => createPath(page), 38 | chunkName: ({ page }) => page, 39 | resolve: isWebpack && (({ page }) => createPath(page)) 40 | }) 41 | 42 | return props => ( 43 |
44 | {props.one ? : null} 45 | {props.two ? : null} 46 | {props.three ? : null} 47 |
48 | ) 49 | } 50 | 51 | export const createBablePluginApp = isWebpack => { 52 | const create = name => { 53 | const importAsync = createBablePluginComponent( 54 | 40, 55 | null, 56 | new Error('test error'), 57 | createPath(name) 58 | ) 59 | return universal(importAsync, { testBabelPlugin: true }) 60 | } 61 | 62 | const Component1 = create('component') 63 | const Component2 = create('component2') 64 | const Component3 = create('component3') 65 | 66 | return props => ( 67 |
68 | {props.one ? : null} 69 | {props.two ? : null} 70 | {props.three ? : null} 71 |
72 | ) 73 | } 74 | 75 | export const createDynamicBablePluginApp = isWebpack => { 76 | const create = name => { 77 | const importAsync = createDynamicBablePluginComponent() 78 | return universal(importAsync, { testBabelPlugin: true }) 79 | } 80 | 81 | const Component1 = create('component') 82 | const Component2 = create('component2') 83 | const Component3 = create('component3') 84 | 85 | return props => ( 86 |
87 | {props.one ? : null} 88 | {props.two ? : null} 89 | {props.three ? : null} 90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /__test-helpers__/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import React from 'react' 3 | import slash from 'slash' 4 | 5 | // fake delay so we can test different stages of async loading lifecycle 6 | export const waitFor = ms => new Promise(resolve => setTimeout(resolve, ms)) 7 | 8 | // normalize the required path so tests pass in all environments 9 | export const normalizePath = path => slash(path.split('__fixtures__')[1]) 10 | 11 | export const createPath = name => path.join(__dirname, '../__fixtures__', name) 12 | 13 | export const Loading = props =>

Loading... {JSON.stringify(props)}

14 | export const Err = props =>

Error! {JSON.stringify(props)}

15 | export const MyComponent = props =>

MyComponent {JSON.stringify(props)}

16 | export const MyComponent2 = props =>

MyComponent {JSON.stringify(props)}

17 | 18 | export const createComponent = ( 19 | delay, 20 | Component, 21 | error = new Error('test error') 22 | ) => async () => { 23 | await waitFor(delay) 24 | if (Component) return Component 25 | throw error 26 | } 27 | 28 | export const createDynamicComponent = ( 29 | delay, 30 | components, 31 | error = new Error('test error') 32 | ) => async (props, tools) => { 33 | await waitFor(delay) 34 | const Component = components[props.page] 35 | if (Component) return Component 36 | throw error 37 | } 38 | 39 | export const createBablePluginComponent = ( 40 | delay, 41 | Component, 42 | error = new Error('test error'), 43 | name 44 | ) => { 45 | const asyncImport = async () => { 46 | await waitFor(delay) 47 | if (Component) return Component 48 | throw error 49 | } 50 | 51 | return { 52 | chunkName: () => name, 53 | path: () => name, 54 | resolve: () => name, 55 | load: () => Promise.all([asyncImport(), 'css']).then(prom => prom[0]), 56 | id: name, 57 | file: `${name}.js` 58 | } 59 | } 60 | 61 | export const createDynamicBablePluginComponent = ( 62 | delay, 63 | components, 64 | error = new Error('test error') 65 | ) => { 66 | const asyncImport = async page => { 67 | await waitFor(delay) 68 | const Component = components[page] 69 | if (Component) return Component 70 | throw error 71 | } 72 | 73 | return ({ page }) => ({ 74 | chunkName: () => page, 75 | path: () => createPath(page), 76 | resolve: () => createPath(page), 77 | load: () => Promise.all([asyncImport(page), 'css']).then(prom => prom[0]), 78 | id: page, 79 | file: `${page}.js` 80 | }) 81 | } 82 | 83 | export const dynamicBabelNodeComponent = ({ page }) => ({ 84 | chunkName: () => page, 85 | path: () => createPath(page), 86 | resolve: () => createPath(page), 87 | id: page, 88 | file: `${page}.js` 89 | }) 90 | 91 | export const createDynamicComponentAndOptions = ( 92 | delay, 93 | components, 94 | error = new Error('test error') 95 | ) => { 96 | const asyncImport = async page => { 97 | await waitFor(delay) 98 | const Component = components[page] 99 | if (Component) return Component 100 | throw error 101 | } 102 | 103 | const load = ({ page }) => 104 | Promise.all([asyncImport(page), 'css']).then(prom => prom[0]) 105 | 106 | const options = { 107 | chunkName: ({ page }) => page, 108 | path: ({ page }) => createPath(page), 109 | resolve: ({ page }) => createPath(page) 110 | } 111 | 112 | return { load, options } 113 | } 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-universal-component", 3 | "version": "4.0.0-alpha.4", 4 | "description": "A higher order component for loading components with promises", 5 | "main": "dist/index.js", 6 | "typings": "index.d.ts", 7 | "author": "James FaceySpacey Gillmore (http://www.faceyspacey.com)", 8 | "contributors": [ 9 | "Zack Jackson (https://github.com/ScriptedAlchemy)" 10 | ], 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/faceyspacey/react-universal-component/issues" 14 | }, 15 | "homepage": "https://github.com/faceyspacey/react-universal-component#readme", 16 | "keywords": [ 17 | "universal", 18 | "ruc", 19 | "unviersal react", 20 | "ssr", 21 | "code splitting", 22 | "aggressive code splitting", 23 | "lodable", 24 | "react", 25 | "async component", 26 | "universal react", 27 | "async rendering", 28 | "webpack 4" 29 | ], 30 | "scripts": { 31 | "build": "babel src -d dist", 32 | "flow-copy": "flow-copy-source src dist -i 'requireById/index.js'", 33 | "flow-watch": "clear; printf \"\\033[3J\" & npm run flow & fswatch -o ./ | xargs -n1 -I{} sh -c 'clear; printf \"\\033[3J\" && npm run flow'", 34 | "flow": "flow; test $? -eq 0 -o $? -eq 2", 35 | "clean": "rimraf dist && mkdir dist", 36 | "test": "jest", 37 | "lint": "eslint --fix ./", 38 | "format": "prettier --single-quote --parser=flow --semi=false --write '{src,__tests__,__fixtures__}/**/*.js' && npm run lint", 39 | "precommit": "lint-staged && npm test", 40 | "cm": "git-cz", 41 | "semantic-release": "semantic-release pre && npm publish && semantic-release post", 42 | "prepublish": "npm run clean && npm run build && npm run flow-copy" 43 | }, 44 | "devDependencies": { 45 | "babel-cli": "^6.26.0", 46 | "babel-core": "^6.26.3", 47 | "babel-eslint": "^7.2.3", 48 | "babel-plugin-transform-flow-strip-types": "^6.22.0", 49 | "babel-plugin-universal-import": "^4.0.0", 50 | "babel-preset-es2015": "^6.24.1", 51 | "babel-preset-flow": "^6.23.0", 52 | "babel-preset-react": "^6.24.1", 53 | "babel-preset-stage-0": "^6.24.1", 54 | "commitizen": "^2.9.6", 55 | "cz-conventional-changelog": "^2.0.0", 56 | "eslint": "^3.19.0", 57 | "eslint-config-airbnb": "^15.0.1", 58 | "eslint-plugin-flowtype": "^2.32.1", 59 | "eslint-plugin-import": "^2.2.0", 60 | "eslint-plugin-jsx-a11y": "^5.0.3", 61 | "eslint-plugin-react": "^7.0.1", 62 | "flow-bin": "^0.49.1", 63 | "flow-copy-source": "^1.1.0", 64 | "husky": "^0.14.3", 65 | "jest": "^20.0.4", 66 | "lint-staged": "^7.2.0", 67 | "prettier": "^1.3.1", 68 | "react": "^16.4.2", 69 | "react-hot-loader": "^4.3.6", 70 | "react-test-renderer": "^17.0.1", 71 | "rimraf": "^2.6.3", 72 | "semantic-release": "^6.3.6", 73 | "slash": "^1.0.0", 74 | "travis-github-status": "^1.6.3" 75 | }, 76 | "peerDependencies": { 77 | "react": "^16.3.0 || ^17.0.0 || ^18.0.0" 78 | }, 79 | "config": { 80 | "commitizen": { 81 | "path": "./node_modules/cz-conventional-changelog" 82 | } 83 | }, 84 | "lint-staged": { 85 | "*.js": [ 86 | "prettier --single-quote --parser=flow --semi=false --write", 87 | "eslint --fix", 88 | "git add" 89 | ] 90 | }, 91 | "repository": { 92 | "type": "git", 93 | "url": "https://github.com/faceyspacey/react-universal-component.git" 94 | }, 95 | "dependencies": { 96 | "hoist-non-react-statics": "^3.3.0", 97 | "prop-types": "^15.7.2" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | parserOptions: { 4 | ecmaFeatures: { 5 | generators: true, 6 | experimentalObjectRestSpread: true 7 | }, 8 | sourceType: 'module', 9 | allowImportExportEverywhere: false 10 | }, 11 | plugins: ['flowtype'], 12 | extends: ['airbnb', 'plugin:flowtype/recommended'], 13 | settings: { 14 | flowtype: { 15 | onlyFilesWithFlowAnnotation: true 16 | } 17 | }, 18 | globals: { 19 | window: true, 20 | document: true, 21 | __dirname: true, 22 | __DEV__: true, 23 | CONFIG: true, 24 | process: true, 25 | jest: true, 26 | describe: true, 27 | test: true, 28 | it: true, 29 | expect: true, 30 | beforeEach: true, 31 | __webpack_require__: true, 32 | __webpack_modules__: true 33 | }, 34 | 'import/resolver': { 35 | node: { 36 | extensions: ['.js', '.css', '.json', '.styl', '.jsx'] 37 | } 38 | }, 39 | 'import/extensions': ['.js'], 40 | 'import/ignore': ['node_modules', 'flow-typed', '\\.(css|styl|svg|json)$'], 41 | rules: { 42 | 'no-shadow': 0, 43 | 'no-use-before-define': 0, 44 | 'no-param-reassign': 0, 45 | 'react/prop-types': 0, 46 | 'react/no-render-return-value': 0, 47 | 'no-confusing-arrow': 0, 48 | 'no-underscore-dangle': 0, 49 | 'no-plusplus': 0, 50 | 'new-parens': 0, 51 | 'global-require': 0, 52 | camelcase: 1, 53 | 'prefer-template': 1, 54 | 'react/no-array-index-key': 1, 55 | 'react/jsx-indent': 1, 56 | 'dot-notation': 1, 57 | 'import/no-named-default': 1, 58 | 'no-unused-vars': 1, 59 | 'import/no-unresolved': 1, 60 | 'react/no-multi-comp': 1, 61 | 'flowtype/no-weak-types': 0, 62 | camelcase: 0, 63 | 'import/no-dynamic-require': 0, 64 | 'consistent-return': 1, 65 | 'no-empty': 1, 66 | 'no-return-assign': 1, 67 | semi: [2, 'never'], 68 | 'no-console': [2, { allow: ['warn', 'error'] }], 69 | 'flowtype/semi': [2, 'never'], 70 | 'jsx-quotes': [2, 'prefer-single'], 71 | 'react/jsx-filename-extension': [2, { extensions: ['.jsx', '.js'] }], 72 | 'spaced-comment': [2, 'always', { markers: ['?'] }], 73 | 'arrow-parens': [2, 'as-needed', { requireForBlockBody: false }], 74 | 'brace-style': [2, 'stroustrup'], 75 | 'no-unused-expressions': [ 76 | 2, 77 | { 78 | allowShortCircuit: true, 79 | allowTernary: true, 80 | allowTaggedTemplates: true 81 | } 82 | ], 83 | 'import/no-extraneous-dependencies': [ 84 | 'error', 85 | { 86 | devDependencies: true, 87 | optionalDependencies: true, 88 | peerDependencies: true 89 | } 90 | ], 91 | 'comma-dangle': [ 92 | 2, 93 | { 94 | arrays: 'never', 95 | objects: 'never', 96 | imports: 'never', 97 | exports: 'never', 98 | functions: 'never' 99 | } 100 | ], 101 | 'max-len': [ 102 | 'error', 103 | { 104 | code: 80, 105 | tabWidth: 2, 106 | ignoreUrls: true, 107 | ignoreComments: true, 108 | ignoreRegExpLiterals: true, 109 | ignoreStrings: true, 110 | ignoreTemplateLiterals: true 111 | } 112 | ], 113 | 'import/extensions': ['error', 'never'], 114 | 'react/sort-comp': [ 115 | 2, 116 | { 117 | order: [ 118 | 'propTypes', 119 | 'props', 120 | 'state', 121 | 'defaultProps', 122 | 'contextTypes', 123 | 'childContextTypes', 124 | 'getChildContext', 125 | 'static-methods', 126 | 'lifecycle', 127 | 'everything-else', 128 | 'render' 129 | ] 130 | } 131 | ] 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react' 3 | import requireById from './requireById' 4 | 5 | import type { 6 | Id, 7 | Key, 8 | Props, 9 | LoadingComponent, 10 | ErrorComponent, 11 | OnLoad, 12 | Mod, 13 | StrFun 14 | } from './flowTypes' 15 | 16 | export const isTest = process.env.NODE_ENV === 'test' 17 | export const isServer = !( 18 | typeof window !== 'undefined' && 19 | window.document && 20 | window.document.createElement 21 | ) 22 | 23 | export const isWebpack = () => typeof __webpack_require__ !== 'undefined' 24 | export const babelInterop = (mod: ?Mod) => 25 | mod && typeof mod === 'object' && mod.__esModule ? mod.default : mod 26 | 27 | export const DefaultLoading: LoadingComponent = () =>
Loading...
28 | export const DefaultError: ErrorComponent = ({ error }) => ( 29 |
Error: {error && error.message}
30 | ) 31 | 32 | export const tryRequire = (id: Id): ?any => { 33 | try { 34 | return requireById(id) 35 | } 36 | catch (err) { 37 | // warn if there was an error while requiring the chunk during development 38 | // this can sometimes lead the server to render the loading component. 39 | if (process.env.NODE_ENV === 'development') { 40 | console.warn( 41 | `chunk not available for synchronous require yet: ${id}: ${ 42 | err.message 43 | }`, 44 | err.stack 45 | ) 46 | } 47 | } 48 | 49 | return null 50 | } 51 | 52 | export const resolveExport = ( 53 | mod: ?Mod, 54 | key: ?Key, 55 | onLoad: ?OnLoad, 56 | chunkName: ?StrFun, 57 | props: Object, 58 | modCache: Object, 59 | isSync?: boolean = false 60 | ) => { 61 | const exp = findExport(mod, key) 62 | if (onLoad && mod) { 63 | const isServer = typeof window === 'undefined' 64 | const info = { isServer, isSync } 65 | onLoad(mod, info, props) 66 | } 67 | if (chunkName && exp) cacheExport(exp, chunkName, props, modCache) 68 | return exp 69 | } 70 | 71 | export const findExport = (mod: ?Mod, key?: Key): ?any => { 72 | if (typeof key === 'function') { 73 | return key(mod) 74 | } 75 | else if (key === null) { 76 | return mod 77 | } 78 | 79 | return mod && typeof mod === 'object' && key ? mod[key] : babelInterop(mod) 80 | } 81 | 82 | export const createElement = (Component: any, props: {}) => 83 | React.isValidElement(Component) ? ( 84 | React.cloneElement(Component, props) 85 | ) : ( 86 | 87 | ) 88 | 89 | export const createDefaultRender = ( 90 | Loading: LoadingComponent, 91 | Err: ErrorComponent 92 | ) => (props: Props, mod: ?any, isLoading: ?boolean, error: ?Error) => { 93 | if (isLoading) { 94 | return createElement(Loading, props) 95 | } 96 | else if (error) { 97 | return createElement(Err, { ...props, error }) 98 | } 99 | else if (mod) { 100 | // primary usage (for async import loading + errors): 101 | return createElement(mod, props) 102 | } 103 | 104 | return createElement(Loading, props) 105 | } 106 | 107 | export const callForString = (strFun: StrFun, props: Object) => 108 | typeof strFun === 'function' ? strFun(props) : strFun 109 | 110 | export const loadFromCache = ( 111 | chunkName: StrFun, 112 | props: Object, 113 | modCache: Object 114 | ) => !isServer && modCache[callForString(chunkName, props)] 115 | 116 | export const cacheExport = ( 117 | exp: any, 118 | chunkName: StrFun, 119 | props: Object, 120 | modCache: Object 121 | ) => (modCache[callForString(chunkName, props)] = exp) 122 | 123 | export const loadFromPromiseCache = ( 124 | chunkName: StrFun, 125 | props: Object, 126 | promisecache: Object 127 | ) => promisecache[callForString(chunkName, props)] 128 | 129 | export const cacheProm = ( 130 | pr: Promise<*>, 131 | chunkName: StrFun, 132 | props: Object, 133 | promisecache: Object 134 | ) => (promisecache[callForString(chunkName, props)] = pr) 135 | -------------------------------------------------------------------------------- /src/flowTypes.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react' 3 | 4 | // config object transformed from import() (babel-plugin-universal-import) 5 | export type StrFun = string | ((props?: Object) => string) 6 | export type Config = { 7 | chunkName: StrFun, 8 | path: StrFun, 9 | resolve: StrFun, 10 | load: Load, 11 | id: string, 12 | file: string 13 | } 14 | 15 | export type Load = (Object, AsyncFuncTools) => Promise 16 | 17 | // function that returns config (babel-plugin-universal-import) 18 | // $FlowIssue 19 | export type ConfigFunc = (props: Object) => Config 20 | 21 | // promise containing component or function returning it 22 | export type AsyncComponent = 23 | | ((props: Object, AsyncFuncTools) => Promise>) 24 | | Promise> 25 | 26 | // OPTIONS FOR BOTH RUM + RUC 27 | 28 | export type ModuleOptions = { 29 | resolve?: StrFun, // only optional when async-only 30 | chunkName?: string, 31 | path?: StrFun, 32 | key?: Key, 33 | timeout?: number, 34 | onError?: OnError, 35 | onLoad?: OnLoad, 36 | alwaysUpdate?: boolean, 37 | isDynamic: boolean, 38 | modCache: Object, 39 | promCache: Object, 40 | id?: string, 41 | usesBabelPlugin?: boolean, 42 | ignoreBabelRename?: boolean 43 | } 44 | 45 | export type ComponentOptions = { 46 | render?: (Props, mod: ?any, isLoading: ?boolean, error: ?Error) => void, 47 | loading?: LoadingComponent, 48 | error?: ErrorComponent, 49 | minDelay?: number, 50 | alwaysDelay?: boolean, 51 | loadingTransition?: boolean, 52 | testBabelPlugin?: boolean, 53 | 54 | // options for requireAsyncModule: 55 | resolve?: StrFun, 56 | path?: StrFun, 57 | chunkName?: string, 58 | timeout?: number, 59 | key?: Key, 60 | onLoad?: OnLoad, 61 | onError?: OnError, 62 | alwaysUpdate?: boolean, 63 | id?: string 64 | } 65 | 66 | // RUM 67 | 68 | export type AsyncFuncTools = { resolve: ResolveImport, reject: RejectImport } 69 | export type ResolveImport = (module: ?any) => void 70 | export type RejectImport = (error: Object) => void 71 | export type Id = string 72 | export type Key = string | null | ((module: ?(Object | Function)) => any) 73 | export type OnLoad = ( 74 | module: ?(Object | Function), 75 | info: { isServer: boolean }, 76 | props: Object 77 | ) => void 78 | export type OnError = (error: Object, info: { isServer: boolean }) => void 79 | 80 | export type RequireAsync = (props: Object) => Promise 81 | export type RequireSync = (props: Object) => ?any 82 | export type AddModule = (props: Object) => ?string 83 | export type Mod = Object | Function 84 | export type Tools = { 85 | requireAsync: RequireAsync, 86 | requireSync: RequireSync, 87 | addModule: AddModule, 88 | shouldUpdate: (nextProps: Object, props: Object) => boolean, 89 | asyncOnly: boolean 90 | } 91 | 92 | export type Ids = Array 93 | 94 | // RUC 95 | export type State = { error?: any, mod?: ?any } 96 | 97 | type Info = { isMount: boolean, isSync: boolean, isServer: boolean } 98 | type OnBefore = Info => void 99 | type OnAfter = (Info, any) => void 100 | type OnErrorProp = (error: { message: string }) => void 101 | 102 | export type Props = { 103 | error?: ?any, 104 | isLoading?: ?boolean, 105 | onBefore?: OnBefore, 106 | onAfter?: OnAfter, 107 | onError?: OnErrorProp 108 | } 109 | 110 | export type Context = { 111 | report?: (chunkName: string) => void 112 | } 113 | 114 | export type GenericComponent = Props => 115 | | React$Element 116 | | Class> 117 | | React$Element 118 | 119 | export type Component = GenericComponent 120 | export type LoadingComponent = GenericComponent<{}> 121 | export type ErrorComponent = GenericComponent<{ error: Error }> 122 | 123 | // babel-plugin-universal-import 124 | export type ImportModule = 125 | | { 126 | default?: Object | Function 127 | } 128 | | Object 129 | | Function 130 | | ImportError 131 | 132 | export type ImportError = { 133 | message: string 134 | } 135 | -------------------------------------------------------------------------------- /src/requireUniversalModule.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { 3 | Tools, 4 | ModuleOptions, 5 | Ids, 6 | Config, 7 | ConfigFunc, 8 | Props, 9 | Load 10 | } from './flowTypes' 11 | 12 | import { 13 | isWebpack, 14 | tryRequire, 15 | resolveExport, 16 | callForString, 17 | loadFromCache, 18 | loadFromPromiseCache, 19 | cacheProm, 20 | isServer, 21 | isTest 22 | } from './utils' 23 | 24 | declare var __webpack_require__: Function 25 | declare var __webpack_modules__: Object 26 | 27 | export const CHUNK_NAMES = new Set() 28 | export const MODULE_IDS = new Set() 29 | 30 | export default function requireUniversalModule( 31 | universalConfig: Config | ConfigFunc, 32 | options: ModuleOptions, 33 | props: Props, 34 | prevProps?: Props 35 | ): Tools { 36 | const { 37 | key, 38 | timeout = 15000, 39 | onLoad, 40 | onError, 41 | isDynamic, 42 | modCache, 43 | promCache, 44 | usesBabelPlugin 45 | } = options 46 | 47 | const config = getConfig(isDynamic, universalConfig, options, props) 48 | const { chunkName, path, resolve, load } = config 49 | const asyncOnly = (!path && !resolve) || typeof chunkName === 'function' 50 | 51 | const requireSync = (props: Object): ?any => { 52 | let exp = loadFromCache(chunkName, props, modCache) 53 | 54 | if (!exp) { 55 | let mod 56 | 57 | if (!isWebpack() && path) { 58 | const modulePath = callForString(path, props) || '' 59 | mod = tryRequire(modulePath) 60 | } 61 | else if (isWebpack() && resolve) { 62 | const weakId = callForString(resolve, props) 63 | 64 | if (__webpack_modules__[weakId]) { 65 | mod = tryRequire(weakId) 66 | } 67 | } 68 | 69 | if (mod) { 70 | exp = resolveExport(mod, key, onLoad, chunkName, props, modCache, true) 71 | } 72 | } 73 | 74 | return exp 75 | } 76 | 77 | const requireAsync = (props: Object): Promise => { 78 | const exp = loadFromCache(chunkName, props, modCache) 79 | if (exp) return Promise.resolve(exp) 80 | 81 | const cachedPromise = loadFromPromiseCache(chunkName, props, promCache) 82 | if (cachedPromise) return cachedPromise 83 | 84 | const prom = new Promise((res, rej) => { 85 | const reject = error => { 86 | error = error || new Error('timeout exceeded') 87 | clearTimeout(timer) 88 | if (onError) { 89 | const isServer = typeof window === 'undefined' 90 | const info = { isServer } 91 | onError(error, info) 92 | } 93 | rej(error) 94 | } 95 | 96 | // const timer = timeout && setTimeout(reject, timeout) 97 | const timer = timeout && setTimeout(reject, timeout) 98 | 99 | const resolve = mod => { 100 | clearTimeout(timer) 101 | 102 | const exp = resolveExport(mod, key, onLoad, chunkName, props, modCache) 103 | if (exp) return res(exp) 104 | 105 | reject(new Error('export not found')) 106 | } 107 | 108 | const request = load(props, { resolve, reject }) 109 | 110 | // if load doesn't return a promise, it must call resolveImport 111 | // itself. Most common is the promise implementation below. 112 | if (!request || typeof request.then !== 'function') return 113 | request.then(resolve).catch(reject) 114 | }) 115 | 116 | cacheProm(prom, chunkName, props, promCache) 117 | return prom 118 | } 119 | 120 | const addModule = (props: Object): ?string => { 121 | if (isServer || isTest) { 122 | if (chunkName) { 123 | let name = callForString(chunkName, props) 124 | if (usesBabelPlugin) { 125 | // if ignoreBabelRename is true, don't apply regex 126 | const shouldKeepName = options && !!options.ignoreBabelRename 127 | if (!shouldKeepName) { 128 | name = name.replace(/\//g, '-') 129 | } 130 | } 131 | if (name) CHUNK_NAMES.add(name) 132 | if (!isTest) return name // makes tests way smaller to run both kinds 133 | } 134 | 135 | if (isWebpack()) { 136 | const weakId = callForString(resolve, props) 137 | if (weakId) MODULE_IDS.add(weakId) 138 | return weakId 139 | } 140 | 141 | if (!isWebpack()) { 142 | const modulePath = callForString(path, props) 143 | if (modulePath) MODULE_IDS.add(modulePath) 144 | return modulePath 145 | } 146 | } 147 | } 148 | 149 | const shouldUpdate = (next, prev): boolean => { 150 | const cacheKey = callForString(chunkName, next) 151 | 152 | const config = getConfig(isDynamic, universalConfig, options, prev) 153 | const prevCacheKey = callForString(config.chunkName, prev) 154 | 155 | return cacheKey !== prevCacheKey 156 | } 157 | 158 | return { 159 | requireSync, 160 | requireAsync, 161 | addModule, 162 | shouldUpdate, 163 | asyncOnly 164 | } 165 | } 166 | 167 | export const flushChunkNames = (): Ids => { 168 | const chunks = Array.from(CHUNK_NAMES) 169 | CHUNK_NAMES.clear() 170 | return chunks 171 | } 172 | 173 | export const flushModuleIds = (): Ids => { 174 | const ids = Array.from(MODULE_IDS) 175 | MODULE_IDS.clear() 176 | return ids 177 | } 178 | 179 | export const clearChunks = (): void => { 180 | CHUNK_NAMES.clear() 181 | MODULE_IDS.clear() 182 | } 183 | 184 | const getConfig = ( 185 | isDynamic: ?boolean, 186 | universalConfig: Config | ConfigFunc, 187 | options: ModuleOptions, 188 | props: Props 189 | ): Config => { 190 | if (isDynamic) { 191 | let resultingConfig = 192 | typeof universalConfig === 'function' 193 | ? universalConfig(props) 194 | : universalConfig 195 | if (options) { 196 | resultingConfig = { ...resultingConfig, ...options } 197 | } 198 | return resultingConfig 199 | } 200 | 201 | const load: Load = 202 | typeof universalConfig === 'function' 203 | ? universalConfig 204 | : // $FlowIssue 205 | () => universalConfig 206 | 207 | return { 208 | file: 'default', 209 | id: options.id || 'default', 210 | chunkName: options.chunkName || 'default', 211 | resolve: options.resolve || '', 212 | path: options.path || '', 213 | load, 214 | ignoreBabelRename: true 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-universal-component' { 2 | import * as React from 'react'; 3 | 4 | type ReportChunksProps = { 5 | report(chunkName: string | undefined): void; 6 | children?: React.ReactNode; 7 | }; 8 | 9 | export class ReportChunks extends React.Component {} 10 | 11 | type ComponentType

= 12 | | React.ComponentType

13 | | React.FunctionComponent

14 | | React.ComponentClass

15 | | React.Component

; 16 | 17 | type Info = { 18 | /** Whether the component just mounted */ 19 | isMount: boolean; 20 | 21 | /** Whether the imported component is already available from previous usage and required synchronsouly */ 22 | isSync: boolean; 23 | 24 | /** 25 | * Very rarely will you want to do stuff on the server. 26 | * 27 | * _Note: server will always be sync_ 28 | */ 29 | isServer: boolean; 30 | }; 31 | 32 | type UniversalProps = { 33 | isLoading: boolean; 34 | error: Error | undefined; 35 | 36 | /** 37 | * `onBefore`/`onAfter` are callbacks called before and after the wrapped component 38 | * loads/changes on both `componentWillMount` and `componentWillReceiveProps`. 39 | * This enables you to display loading indicators elsewhere in the UI. 40 | */ 41 | onBefore( 42 | info: Info 43 | ): void; 44 | 45 | /** 46 | * `onBefore`/`onAfter` are callbacks called before and after the wrapped component 47 | * loads/changes on both `componentWillMount` and `componentWillReceiveProps`. 48 | * This enables you to display loading indicators elsewhere in the UI. 49 | */ 50 | onAfter( 51 | info: Info 52 | ): void; 53 | 54 | /** 55 | * `onError` is similar to the onError static option, except it operates at the component 56 | * level. Therefore you can bind to this of the parent component and call 57 | * `this.setState()` or `this.props.dispatch()`. 58 | * Again, it's use case is for when you want to show error information elsewhere in the 59 | * UI besides just the place that the universal component would otherwise render. 60 | */ 61 | onError( 62 | error: Error 63 | ): void; 64 | }; 65 | 66 | type UniversalComponent

= React.FunctionComponent< 67 | P & Partial 68 | > & { 69 | preload(props?: P): void; 70 | }; 71 | 72 | type Module

= 73 | | { 74 | default?: P; 75 | [x: string]: any; 76 | } 77 | | { 78 | exports?: P; 79 | [x: string]: any; 80 | }; 81 | 82 | type Options = Partial< 83 | { 84 | /** 85 | * Lets you specify the export from the module you want to be your component 86 | * if it's not `default` in ES6 or `module.exports` in ES5. 87 | * It can be a string corresponding to the export key, or a function that's 88 | * passed the entire module and returns the export that will become the component. 89 | */ 90 | key: keyof Export | ((module: Export) => ComponentType

); 91 | 92 | /** 93 | * Allows you to specify a maximum amount of time before the error component 94 | * is displayed. The default is 15 seconds. 95 | */ 96 | timeout: number; 97 | 98 | /** 99 | * `minDelay` is essentially the minimum amount of time the loading component 100 | * will always show for. It's good for enforcing silky smooth animations, such as 101 | * during a 500ms sliding transition. It insures the re-render won't happen 102 | * until the animation is complete. It's often a good idea to set this to something 103 | * like 300ms even if you don't have a transition, just so the loading spinner 104 | * shows for an appropriate amount of time without jank. 105 | */ 106 | minDelay: number; 107 | 108 | /** 109 | * `alwaysDelay` is a boolean you can set to true (default: `false`) to guarantee the 110 | * `minDelay` is always used (i.e. even when components cached from previous imports 111 | * and therefore synchronously and instantly required). This can be useful for 112 | * guaranteeing animations operate as you want without having to wire up other 113 | * components to perform the task. 114 | * _Note: this only applies to the client when 115 | * your `UniversalComponent` uses dynamic expressions to switch between multiple 116 | * components._ 117 | * 118 | * default: `false` 119 | */ 120 | alwaysDelay: boolean; 121 | 122 | /** 123 | * When set to `false` allows you to keep showing the current component when the 124 | * loading component would otherwise show during transitions from one component to 125 | * the next. 126 | */ 127 | loadingTransition: boolean; 128 | 129 | /** 130 | * `ignoreBabelRename` is by default set to false which allows the plugin to attempt 131 | * and name the dynamically imported chunk (replacing / with -). 132 | * In more advanced scenarios where more granular control is required over the webpack chunk name, 133 | * you should set this to true in addition to providing a function to chunkName to control chunk naming. 134 | */ 135 | ignoreBabelRename: boolean; 136 | 137 | testBabelPlugin: boolean; 138 | 139 | resolve: string | number | ((props: P) => number | string); 140 | 141 | path: string | ((props: P) => string); 142 | 143 | chunkName: string | ((props: P) => string); 144 | 145 | alwaysUpdate: boolean; 146 | 147 | id: string; 148 | 149 | /** 150 | * A callback called if async imports fail. 151 | * It does not apply to sync requires. 152 | */ 153 | onError( 154 | error: Error, 155 | options: { isServer: boolean } 156 | ): void; 157 | 158 | /** 159 | * A callback function that receives the entire module. 160 | * It allows you to export and put to use things other than your 161 | * default component export, like reducers, sagas, etc. 162 | * 163 | * `onLoad` is fired directly before the component is rendered so you can setup 164 | * any reducers/etc it depends on. Unlike the `onAfter` prop, this option to the 165 | * `universal` HOC is only fired the first time the module is received. Also 166 | * note: it will fire on the server, so do if (!isServer) if you have to. 167 | * But also keep in mind you will need to do things like replace reducers on 168 | * both the server + client for the imported component that uses new reducers 169 | * to render identically in both places. 170 | */ 171 | onLoad( 172 | module: Export, 173 | options: { isSync: boolean; isServer: boolean } 174 | ): void; 175 | 176 | /** 177 | * The component class or function corresponding to your stateless component 178 | * that displays while the primary import is loading. 179 | * While testing out this package, you can leave it out as a simple default one is used. 180 | */ 181 | loading: 182 | | ((p: P) => JSX.Element | ComponentType

) 183 | | (JSX.Element | ComponentType

); 184 | 185 | /** 186 | * The component that displays if there are any errors that occur during 187 | * your aynschronous import. While testing out this package, 188 | * you can leave it out as a simple default one is used. 189 | */ 190 | error: 191 | | ((p: P) => JSX.Element | ComponentType

) 192 | | (JSX.Element | ComponentType

); 193 | 194 | render: ( 195 | props: P, 196 | module: Export | undefined, 197 | isLoading: boolean, 198 | error: Error | undefined 199 | ) => JSX.Element 200 | } 201 | >; 202 | 203 | export default function universal< 204 | P, 205 | C extends ComponentType

= ComponentType

, 206 | Export extends Module = Module 207 | >( 208 | loadSpec: 209 | | PromiseLike 210 | | ((props: P) => PromiseLike) 211 | | { 212 | load(props: P): PromiseLike; 213 | }, 214 | options?: Options 215 | ): UniversalComponent

; 216 | } 217 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react' 3 | import PropTypes from 'prop-types' 4 | import hoist from 'hoist-non-react-statics' 5 | import req from './requireUniversalModule' 6 | import type { 7 | Config, 8 | ConfigFunc, 9 | ComponentOptions, 10 | RequireAsync, 11 | State, 12 | Props, 13 | Context 14 | } from './flowTypes' 15 | import ReportContext from './context' 16 | 17 | import { 18 | DefaultLoading, 19 | DefaultError, 20 | createDefaultRender, 21 | isServer 22 | } from './utils' 23 | import { __update } from './helpers' 24 | 25 | export { CHUNK_NAMES, MODULE_IDS } from './requireUniversalModule' 26 | export { default as ReportChunks } from './report-chunks' 27 | 28 | let hasBabelPlugin = false 29 | 30 | const isHMR = () => 31 | // $FlowIgnore 32 | module.hot && (module.hot.data || module.hot.status() === 'apply') 33 | 34 | export const setHasBabelPlugin = () => { 35 | hasBabelPlugin = true 36 | } 37 | 38 | export default function universal( 39 | asyncModule: Config | ConfigFunc, 40 | opts: ComponentOptions = {} 41 | ) { 42 | const { 43 | render: userRender, 44 | loading: Loading = DefaultLoading, 45 | error: Err = DefaultError, 46 | minDelay = 0, 47 | alwaysDelay = false, 48 | testBabelPlugin = false, 49 | loadingTransition = true, 50 | ...options 51 | } = opts 52 | 53 | const renderFunc = userRender || createDefaultRender(Loading, Err) 54 | 55 | const isDynamic = hasBabelPlugin || testBabelPlugin 56 | options.isDynamic = isDynamic 57 | options.usesBabelPlugin = hasBabelPlugin 58 | options.modCache = {} 59 | options.promCache = {} 60 | 61 | return class UniversalComponent extends React.Component { 62 | /* eslint-disable react/sort-comp */ 63 | _initialized: boolean 64 | _asyncOnly: boolean 65 | 66 | state: State 67 | props: Props 68 | /* eslint-enable react/sort-comp */ 69 | 70 | static contextType = ReportContext 71 | 72 | static preload(props: Props) { 73 | props = props || {} 74 | const { requireAsync, requireSync } = req(asyncModule, options, props) 75 | let mod 76 | 77 | try { 78 | mod = requireSync(props) 79 | } 80 | catch (error) { 81 | return Promise.reject(error) 82 | } 83 | 84 | return Promise.resolve() 85 | .then(() => { 86 | if (mod) return mod 87 | return requireAsync(props) 88 | }) 89 | .then(mod => { 90 | hoist(UniversalComponent, mod, { 91 | preload: true, 92 | preloadWeak: true 93 | }) 94 | return mod 95 | }) 96 | } 97 | 98 | static preloadWeak(props: Props) { 99 | props = props || {} 100 | const { requireSync } = req(asyncModule, options, props) 101 | 102 | const mod = requireSync(props) 103 | if (mod) { 104 | hoist(UniversalComponent, mod, { 105 | preload: true, 106 | preloadWeak: true 107 | }) 108 | } 109 | 110 | return mod 111 | } 112 | 113 | requireAsyncInner( 114 | requireAsync: RequireAsync, 115 | props: Props, 116 | state: State, 117 | isMount?: boolean 118 | ) { 119 | if (!state.mod && loadingTransition) { 120 | this.update({ mod: null, props }) // display `loading` during componentWillReceiveProps 121 | } 122 | 123 | const time = new Date() 124 | 125 | requireAsync(props) 126 | .then((mod: ?any) => { 127 | const state = { mod, props } 128 | 129 | const timeLapsed = new Date() - time 130 | if (timeLapsed < minDelay) { 131 | const extraDelay = minDelay - timeLapsed 132 | return setTimeout(() => this.update(state, isMount), extraDelay) 133 | } 134 | 135 | this.update(state, isMount) 136 | }) 137 | .catch(error => this.update({ error, props })) 138 | } 139 | 140 | update = ( 141 | state: State, 142 | isMount?: boolean = false, 143 | isSync?: boolean = false, 144 | isServer?: boolean = false 145 | ) => { 146 | if (!this._initialized) return 147 | if (!state.error) state.error = null 148 | 149 | this.handleAfter(state, isMount, isSync, isServer) 150 | } 151 | 152 | handleBefore( 153 | isMount: boolean, 154 | isSync: boolean, 155 | isServer?: boolean = false 156 | ) { 157 | if (this.props.onBefore) { 158 | const { onBefore } = this.props 159 | const info = { isMount, isSync, isServer } 160 | onBefore(info) 161 | } 162 | } 163 | 164 | handleAfter( 165 | state: State, 166 | isMount: boolean, 167 | isSync: boolean, 168 | isServer: boolean 169 | ) { 170 | const { mod, error } = state 171 | 172 | if (mod && !error) { 173 | hoist(UniversalComponent, mod, { 174 | preload: true, 175 | preloadWeak: true 176 | }) 177 | 178 | if (this.props.onAfter) { 179 | const { onAfter } = this.props 180 | const info = { isMount, isSync, isServer } 181 | onAfter(info, mod) 182 | } 183 | } 184 | else if (error && this.props.onError) { 185 | this.props.onError(error) 186 | } 187 | 188 | this.setState(state) 189 | } 190 | // $FlowFixMe 191 | init(props) { 192 | const { addModule, requireSync, requireAsync, asyncOnly } = req( 193 | asyncModule, 194 | options, 195 | props 196 | ) 197 | 198 | let mod 199 | 200 | try { 201 | mod = requireSync(props) 202 | } 203 | catch (error) { 204 | return __update(props, { error, props }, this._initialized) 205 | } 206 | 207 | this._asyncOnly = asyncOnly 208 | const chunkName = addModule(props) // record the module for SSR flushing :) 209 | if (this.context && this.context.report) { 210 | this.context.report(chunkName) 211 | } 212 | 213 | if (mod || isServer) { 214 | this.handleBefore(true, true, isServer) 215 | return __update( 216 | props, 217 | { asyncOnly, props, mod }, 218 | this._initialized, 219 | true, 220 | true, 221 | isServer 222 | ) 223 | } 224 | 225 | this.handleBefore(true, false) 226 | this.requireAsyncInner( 227 | requireAsync, 228 | props, 229 | { props, asyncOnly, mod }, 230 | true 231 | ) 232 | return { mod, asyncOnly, props } 233 | } 234 | 235 | constructor(props: Props, context: Context) { 236 | super(props, context) 237 | this.state = this.init(this.props) 238 | // $FlowFixMe 239 | this.state.error = null 240 | } 241 | 242 | static getDerivedStateFromProps(nextProps, currentState) { 243 | const { requireSync, shouldUpdate } = req( 244 | asyncModule, 245 | options, 246 | nextProps, 247 | currentState.props 248 | ) 249 | if (isHMR() && shouldUpdate(currentState.props, nextProps)) { 250 | const mod = requireSync(nextProps) 251 | return { ...currentState, mod } 252 | } 253 | return null 254 | } 255 | 256 | componentDidMount() { 257 | this._initialized = true 258 | } 259 | 260 | componentDidUpdate(prevProps: Props) { 261 | if (isDynamic || this._asyncOnly) { 262 | const { requireSync, requireAsync, shouldUpdate } = req( 263 | asyncModule, 264 | options, 265 | this.props, 266 | prevProps 267 | ) 268 | 269 | if (shouldUpdate(this.props, prevProps)) { 270 | let mod 271 | 272 | try { 273 | mod = requireSync(this.props) 274 | } 275 | catch (error) { 276 | return this.update({ error }) 277 | } 278 | 279 | this.handleBefore(false, !!mod) 280 | 281 | if (!mod) { 282 | return this.requireAsyncInner(requireAsync, this.props, { mod }) 283 | } 284 | 285 | const state = { mod } 286 | 287 | if (alwaysDelay) { 288 | if (loadingTransition) this.update({ mod: null }) // display `loading` during componentWillReceiveProps 289 | setTimeout(() => this.update(state, false, true), minDelay) 290 | return 291 | } 292 | 293 | this.update(state, false, true) 294 | } 295 | } 296 | } 297 | 298 | componentWillUnmount() { 299 | this._initialized = false 300 | } 301 | 302 | render() { 303 | const { isLoading, error: userError, ...props } = this.props 304 | const { mod, error } = this.state 305 | return renderFunc(props, mod, isLoading, userError || error) 306 | } 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/index.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`advanced Component.preload: static preload method pre-fetches chunk 1`] = ` 4 |

5 | Loading... 6 |
7 | `; 8 | 9 | exports[`advanced Component.preload: static preload method pre-fetches chunk 2`] = ` 10 |

11 | MyComponent 12 | {} 13 |

14 | `; 15 | 16 | exports[`advanced Component.preload: static preload method pre-fetches chunk 3`] = ` 17 |

18 | MyComponent 19 | {} 20 |

21 | `; 22 | 23 | exports[`advanced babel-plugin 1`] = ` 24 |
25 | Loading... 26 |
27 | `; 28 | 29 | exports[`advanced babel-plugin 2`] = ` 30 |

31 | MyComponent 32 | {} 33 |

34 | `; 35 | 36 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) (no babel plugin) 1`] = ` 37 |
38 | Loading... 39 |
40 | `; 41 | 42 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) (no babel plugin) 2`] = ` 43 |

44 | MyComponent 45 | {"page":"MyComponent"} 46 |

47 | `; 48 | 49 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) (no babel plugin) 3`] = ` 50 |
51 | Loading... 52 |
53 | `; 54 | 55 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) (no babel plugin) 4`] = ` 56 |

57 | MyComponent 58 | {"page":"MyComponent2"} 59 |

60 | `; 61 | 62 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) 1`] = ` 63 |
64 | Loading... 65 |
66 | `; 67 | 68 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) 2`] = ` 69 |

70 | MyComponent 71 | {"page":"MyComponent"} 72 |

73 | `; 74 | 75 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) 3`] = ` 76 |
77 | Loading... 78 |
79 | `; 80 | 81 | exports[`advanced componentWillReceiveProps: changes component (dynamic require) 4`] = ` 82 |

83 | MyComponent 84 | {"page":"MyComponent2"} 85 |

86 | `; 87 | 88 | exports[`advanced dynamic requires (async) 1`] = ` 89 |

90 | MyComponent 91 | {"page":"MyComponent"} 92 |

93 | `; 94 | 95 | exports[`advanced promise passed directly 1`] = ` 96 |
97 | Loading... 98 |
99 | `; 100 | 101 | exports[`advanced promise passed directly 2`] = ` 102 |

103 | MyComponent 104 | {} 105 |

106 | `; 107 | 108 | exports[`async lifecycle component unmounted: setState not called 1`] = ` 109 |
110 | Loading... 111 |
112 | `; 113 | 114 | exports[`async lifecycle error 1`] = ` 115 |
116 | Loading... 117 |
118 | `; 119 | 120 | exports[`async lifecycle error 2`] = ` 121 |
122 | Loading... 123 |
124 | `; 125 | 126 | exports[`async lifecycle error 3`] = ` 127 |
128 | Error: 129 | test error 130 |
131 | `; 132 | 133 | exports[`async lifecycle loading 1`] = ` 134 |
135 | Loading... 136 |
137 | `; 138 | 139 | exports[`async lifecycle loading 2`] = ` 140 |
141 | Loading... 142 |
143 | `; 144 | 145 | exports[`async lifecycle loading 3`] = ` 146 |

147 | MyComponent 148 | {} 149 |

150 | `; 151 | 152 | exports[`async lifecycle loading 4`] = ` 153 |

154 | MyComponent 155 | {} 156 |

157 | `; 158 | 159 | exports[`async lifecycle timeout error 1`] = ` 160 |
161 | Loading... 162 |
163 | `; 164 | 165 | exports[`async lifecycle timeout error 2`] = ` 166 |
167 | Error: 168 | timeout exceeded 169 |
170 | `; 171 | 172 | exports[`other options key (function): resolves export to function return 1`] = ` 173 |
174 | Loading... 175 |
176 | `; 177 | 178 | exports[`other options key (function): resolves export to function return 2`] = ` 179 |
180 | Loading... 181 |
182 | `; 183 | 184 | exports[`other options key (function): resolves export to function return 3`] = ` 185 |

186 | MyComponent 187 | {} 188 |

189 | `; 190 | 191 | exports[`other options key (string): resolves export to value of key 1`] = ` 192 |
193 | Loading... 194 |
195 | `; 196 | 197 | exports[`other options key (string): resolves export to value of key 2`] = ` 198 |
199 | Loading... 200 |
201 | `; 202 | 203 | exports[`other options key (string): resolves export to value of key 3`] = ` 204 |

205 | MyComponent 206 | {} 207 |

208 | `; 209 | 210 | exports[`other options minDelay: loads for duration of minDelay even if component ready 1`] = ` 211 |
212 | Loading... 213 |
214 | `; 215 | 216 | exports[`other options minDelay: loads for duration of minDelay even if component ready 2`] = ` 217 |
218 | Loading... 219 |
220 | `; 221 | 222 | exports[`other options minDelay: loads for duration of minDelay even if component ready 3`] = ` 223 |

224 | MyComponent 225 | {} 226 |

227 | `; 228 | 229 | exports[`other options onLoad (async): is called and passed an es6 module 1`] = ` 230 |

231 | MyComponent 232 | {"foo":"bar"} 233 |

234 | `; 235 | 236 | exports[`other options onLoad (async): is called and passed entire module 1`] = ` 237 |

238 | MyComponent 239 | {} 240 |

241 | `; 242 | 243 | exports[`props: all components receive props - also displays error component 1`] = ` 244 |

245 | Error! 246 | {"error":{}} 247 |

248 | `; 249 | 250 | exports[`props: all components receive props - also displays error component 2`] = ` 251 |

252 | Error! 253 | {"error":{}} 254 |

255 | `; 256 | 257 | exports[`props: all components receive props - also displays loading component 1`] = ` 258 |
259 | Loading... 260 |
261 | `; 262 | 263 | exports[`props: all components receive props - also displays loading component 2`] = ` 264 |
265 | Loading... 266 |
267 | `; 268 | 269 | exports[`props: all components receive props arguments/props passed to asyncComponent function for data-fetching 1`] = ` 270 |
271 | Loading... 272 |
273 | `; 274 | 275 | exports[`props: all components receive props arguments/props passed to asyncComponent function for data-fetching 2`] = ` 276 |
279 | foo 280 |
281 | `; 282 | 283 | exports[`props: all components receive props components passed as elements: error 1`] = ` 284 |
285 | Loading... 286 |
287 | `; 288 | 289 | exports[`props: all components receive props components passed as elements: error 2`] = ` 290 |
291 | Loading... 292 |
293 | `; 294 | 295 | exports[`props: all components receive props components passed as elements: error 3`] = ` 296 |

297 | Error! 298 | {"prop":"foo","error":{}} 299 |

300 | `; 301 | 302 | exports[`props: all components receive props components passed as elements: error 4`] = ` 303 |
304 | Loading... 305 |
306 | `; 307 | 308 | exports[`props: all components receive props components passed as elements: loading 1`] = ` 309 |

310 | Loading... 311 | {"prop":"foo"} 312 |

313 | `; 314 | 315 | exports[`props: all components receive props components passed as elements: loading 2`] = ` 316 |

317 | Loading... 318 | {"prop":"foo"} 319 |

320 | `; 321 | 322 | exports[`props: all components receive props components passed as elements: loading 3`] = ` 323 |

324 | MyComponent 325 | {"prop":"foo"} 326 |

327 | `; 328 | 329 | exports[`props: all components receive props components passed as elements: loading 4`] = ` 330 |

331 | MyComponent 332 | {"prop":"bar"} 333 |

334 | `; 335 | 336 | exports[`props: all components receive props custom error component 1`] = ` 337 |
338 | Loading... 339 |
340 | `; 341 | 342 | exports[`props: all components receive props custom error component 2`] = ` 343 |
344 | Loading... 345 |
346 | `; 347 | 348 | exports[`props: all components receive props custom error component 3`] = ` 349 |

350 | Error! 351 | {"prop":"foo","error":{}} 352 |

353 | `; 354 | 355 | exports[`props: all components receive props custom error component 4`] = ` 356 |
357 | Loading... 358 |
359 | `; 360 | 361 | exports[`props: all components receive props custom loading component 1`] = ` 362 |

363 | Loading... 364 | {"prop":"foo"} 365 |

366 | `; 367 | 368 | exports[`props: all components receive props custom loading component 2`] = ` 369 |

370 | Loading... 371 | {"prop":"foo"} 372 |

373 | `; 374 | 375 | exports[`props: all components receive props custom loading component 3`] = ` 376 |

377 | MyComponent 378 | {"prop":"foo"} 379 |

380 | `; 381 | 382 | exports[`props: all components receive props custom loading component 4`] = ` 383 |

384 | MyComponent 385 | {"prop":"bar"} 386 |

387 | `; 388 | 389 | exports[`server-side rendering es5: module.exports resolved 1`] = ` 390 |
391 | fixture-ES5 392 |
393 | `; 394 | 395 | exports[`server-side rendering es6: default export automatically resolved 1`] = ` 396 |
397 | fixture1 398 |
399 | `; 400 | -------------------------------------------------------------------------------- /__tests__/requireUniversalModule.js: -------------------------------------------------------------------------------- 1 | // @noflow 2 | import path from 'path' 3 | import { createPath, waitFor, normalizePath } from '../__test-helpers__' 4 | 5 | import req, { 6 | flushModuleIds, 7 | flushChunkNames, 8 | clearChunks 9 | } from '../src/requireUniversalModule' 10 | 11 | const requireModule = (asyncImport, options, props) => 12 | req(asyncImport, { ...options, modCache: {}, promCache: {} }, props) 13 | 14 | describe('requireSync: tries to require module synchronously on both the server and client', () => { 15 | it('babel', () => { 16 | const modulePath = createPath('es6') 17 | const { requireSync } = requireModule(undefined, { path: modulePath }) 18 | const mod = requireSync() 19 | 20 | const defaultExport = require(modulePath).default 21 | expect(mod).toEqual(defaultExport) 22 | }) 23 | 24 | it('babel: path option as function', () => { 25 | const modulePath = createPath('es6') 26 | const { requireSync } = requireModule(undefined, { path: () => modulePath }) 27 | const mod = requireSync() 28 | 29 | const defaultExport = require(modulePath).default 30 | expect(mod).toEqual(defaultExport) 31 | }) 32 | 33 | it('webpack', () => { 34 | global.__webpack_require__ = path => __webpack_modules__[path] 35 | const modulePath = createPath('es6') 36 | 37 | global.__webpack_modules__ = { 38 | [modulePath]: require(modulePath) 39 | } 40 | 41 | const options = { resolve: () => modulePath } 42 | const { requireSync } = requireModule(undefined, options) 43 | const mod = requireSync() 44 | 45 | const defaultExport = require(modulePath).default 46 | expect(mod).toEqual(defaultExport) 47 | 48 | delete global.__webpack_require__ 49 | delete global.__webpack_modules__ 50 | }) 51 | 52 | it('webpack: resolve option as string', () => { 53 | global.__webpack_require__ = path => __webpack_modules__[path] 54 | const modulePath = createPath('es6.js') 55 | 56 | global.__webpack_modules__ = { 57 | [modulePath]: require(modulePath) 58 | } 59 | 60 | const { requireSync } = requireModule(undefined, { resolve: modulePath }) 61 | const mod = requireSync() 62 | 63 | const defaultExport = require(modulePath).default 64 | expect(mod).toEqual(defaultExport) 65 | 66 | delete global.__webpack_require__ 67 | delete global.__webpack_modules__ 68 | }) 69 | 70 | it('webpack: when mod is undefined, requireSync used instead after all chunks evaluated at render time', () => { 71 | global.__webpack_require__ = path => __webpack_modules__[path] 72 | const modulePath = createPath('es6') 73 | 74 | // main.js chunk is evaluated, but 0.js comes after 75 | global.__webpack_modules__ = {} 76 | 77 | const { requireSync } = requireModule(undefined, { 78 | resolve: () => modulePath 79 | }) 80 | const mod = requireSync() 81 | 82 | expect(mod).toEqual(undefined) 83 | 84 | // 0.js chunk is evaluated, and now the module exists 85 | global.__webpack_modules__ = { 86 | [modulePath]: require(modulePath) 87 | } 88 | 89 | // requireSync is used, for example, at render time after all chunks are evaluated 90 | const modAttempt2 = requireSync() 91 | const defaultExport = require(modulePath).default 92 | expect(modAttempt2).toEqual(defaultExport) 93 | 94 | delete global.__webpack_require__ 95 | delete global.__webpack_modules__ 96 | }) 97 | 98 | it('es5 resolution', () => { 99 | const { requireSync } = requireModule(undefined, { 100 | path: path.join(__dirname, '../__fixtures__/es5') 101 | }) 102 | const mod = requireSync() 103 | 104 | const defaultExport = require('../__fixtures__/es5') 105 | expect(mod).toEqual(defaultExport) 106 | }) 107 | 108 | it('babel: dynamic require', () => { 109 | const modulePath = ({ page }) => createPath(page) 110 | const props = { page: 'es6' } 111 | const options = { path: modulePath } 112 | const { requireSync } = requireModule(null, options, props) 113 | const mod = requireSync(props) 114 | 115 | const defaultExport = require(createPath('es6')).default 116 | expect(mod).toEqual(defaultExport) 117 | }) 118 | 119 | it('webpack: dynamic require', () => { 120 | global.__webpack_require__ = path => __webpack_modules__[path] 121 | const modulePath = ({ page }) => createPath(page) 122 | 123 | global.__webpack_modules__ = { 124 | [createPath('es6')]: require(createPath('es6')) 125 | } 126 | 127 | const props = { page: 'es6' } 128 | const options = { resolve: modulePath } 129 | const { requireSync } = requireModule(undefined, options, props) 130 | const mod = requireSync(props) 131 | 132 | const defaultExport = require(createPath('es6')).default 133 | expect(mod).toEqual(defaultExport) 134 | 135 | delete global.__webpack_require__ 136 | delete global.__webpack_modules__ 137 | }) 138 | }) 139 | 140 | describe('requireAsync: requires module asynchronously on the client, returning a promise', () => { 141 | it('asyncImport as function: () => import()', async () => { 142 | const { requireAsync } = requireModule(() => Promise.resolve('hurray')) 143 | 144 | const res = await requireAsync() 145 | expect(res).toEqual('hurray') 146 | }) 147 | 148 | it('asyncImport as promise: import()', async () => { 149 | const { requireAsync } = requireModule(Promise.resolve('hurray')) 150 | 151 | const res = await requireAsync() 152 | expect(res).toEqual('hurray') 153 | }) 154 | 155 | it('asyncImport as function using callback for require.ensure: (props, { resolve }) => resolve(module)', async () => { 156 | const { requireAsync } = requireModule((props, { resolve }) => 157 | resolve('hurray') 158 | ) 159 | 160 | const res = await requireAsync() 161 | expect(res).toEqual('hurray') 162 | }) 163 | 164 | it('asyncImport as function using callback for require.ensure: (props, { reject }) => reject(error)', async () => { 165 | const { requireAsync } = requireModule((props, { reject }) => 166 | reject(new Error('ah')) 167 | ) 168 | 169 | try { 170 | await requireAsync() 171 | } 172 | catch (error) { 173 | expect(error.message).toEqual('ah') 174 | } 175 | }) 176 | 177 | it('asyncImport as function with props: props => import()', async () => { 178 | const { requireAsync } = requireModule(props => Promise.resolve(props.foo)) 179 | const res = await requireAsync({ foo: 123 }) 180 | expect(res).toEqual(123) 181 | }) 182 | 183 | it('asyncImport as function with props: (props, { resolve }) => cb()', async () => { 184 | const asyncImport = (props, { resolve }) => resolve(props.foo) 185 | const { requireAsync } = requireModule(asyncImport) 186 | const res = await requireAsync({ foo: 123 }) 187 | expect(res).toEqual(123) 188 | }) 189 | 190 | it('return Promise.resolve(mod) if module already synchronously required', async () => { 191 | const modulePath = createPath('es6') 192 | const options = { path: modulePath } 193 | const { requireSync, requireAsync } = requireModule(undefined, options) 194 | const mod = requireSync() 195 | 196 | expect(mod).toBeDefined() 197 | 198 | const prom = requireAsync() 199 | expect(prom.then).toBeDefined() 200 | 201 | const modAgain = await requireAsync() 202 | expect(modAgain).toEqual('hello') 203 | }) 204 | 205 | it('export not found rejects', async () => { 206 | const { requireAsync } = requireModule(() => Promise.resolve('hurray'), { 207 | key: 'dog' 208 | }) 209 | 210 | try { 211 | await requireAsync() 212 | } 213 | catch (error) { 214 | expect(error.message).toEqual('export not found') 215 | } 216 | }) 217 | 218 | it('rejected promise', async () => { 219 | const { requireAsync } = requireModule(Promise.reject(new Error('ah'))) 220 | 221 | try { 222 | await requireAsync() 223 | } 224 | catch (error) { 225 | expect(error.message).toEqual('ah') 226 | } 227 | }) 228 | 229 | it('rejected promise calls onError', async () => { 230 | const error = new Error('ah') 231 | const onError = jest.fn() 232 | const opts = { onError } 233 | const { requireAsync } = requireModule(Promise.reject(error), opts) 234 | 235 | try { 236 | await requireAsync() 237 | } 238 | catch (error) { 239 | expect(error.message).toEqual('ah') 240 | } 241 | 242 | expect(onError).toBeCalledWith(error, { isServer: false }) 243 | }) 244 | }) 245 | 246 | describe('addModule: add moduleId and chunkName for SSR flushing', () => { 247 | it('babel', () => { 248 | clearChunks() 249 | 250 | const moduleEs6 = createPath('es6') 251 | const moduleEs5 = createPath('es5') 252 | 253 | let universal = requireModule(undefined, { 254 | path: moduleEs6, 255 | chunkName: 'es6' 256 | }) 257 | universal.addModule() 258 | 259 | universal = requireModule(undefined, { path: moduleEs5, chunkName: 'es5' }) 260 | universal.addModule() 261 | 262 | const paths = flushModuleIds().map(normalizePath) 263 | const chunkNames = flushChunkNames() 264 | 265 | expect(paths).toEqual(['/es6', '/es5']) 266 | expect(chunkNames).toEqual(['es6', 'es5']) 267 | }) 268 | 269 | it('webpack', () => { 270 | global.__webpack_require__ = path => __webpack_modules__[path] 271 | 272 | const moduleEs6 = createPath('es6') 273 | const moduleEs5 = createPath('es5') 274 | 275 | // modules stored by paths instead of IDs (replicates babel implementation) 276 | global.__webpack_modules__ = { 277 | [moduleEs6]: require(moduleEs6), 278 | [moduleEs5]: require(moduleEs5) 279 | } 280 | 281 | clearChunks() 282 | 283 | let universal = requireModule(undefined, { 284 | resolve: () => moduleEs6, 285 | chunkName: 'es6' 286 | }) 287 | universal.addModule() 288 | 289 | universal = requireModule(undefined, { 290 | resolve: () => moduleEs5, 291 | chunkName: 'es5' 292 | }) 293 | universal.addModule() 294 | 295 | const paths = flushModuleIds().map(normalizePath) 296 | const chunkNames = flushChunkNames() 297 | 298 | expect(paths).toEqual(['/es6', '/es5']) 299 | expect(chunkNames).toEqual(['es6', 'es5']) 300 | 301 | delete global.__webpack_require__ 302 | delete global.__webpack_modules__ 303 | }) 304 | }) 305 | 306 | describe('other options', () => { 307 | it('key (string): resolve export to value of key', () => { 308 | const modulePath = createPath('es6') 309 | const { requireSync } = requireModule(undefined, { 310 | path: modulePath, 311 | key: 'foo' 312 | }) 313 | const mod = requireSync() 314 | 315 | const defaultExport = require(modulePath).foo 316 | expect(mod).toEqual(defaultExport) 317 | }) 318 | 319 | it('key (function): resolves export to function return', () => { 320 | const modulePath = createPath('es6') 321 | const { requireSync } = requireModule(undefined, { 322 | path: modulePath, 323 | key: module => module.foo 324 | }) 325 | const mod = requireSync() 326 | 327 | const defaultExport = require(modulePath).foo 328 | expect(mod).toEqual(defaultExport) 329 | }) 330 | 331 | it('key (null): resolves export to be entire module', () => { 332 | const { requireSync } = requireModule(undefined, { 333 | path: path.join(__dirname, '../__fixtures__/es6'), 334 | key: null 335 | }) 336 | const mod = requireSync() 337 | 338 | const defaultExport = require('../__fixtures__/es6') 339 | expect(mod).toEqual(defaultExport) 340 | }) 341 | 342 | it('timeout: throws if loading time is longer than timeout', async () => { 343 | const asyncImport = waitFor(20).then('hurray') 344 | const { requireAsync } = requireModule(asyncImport, { timeout: 10 }) 345 | 346 | try { 347 | await requireAsync() 348 | } 349 | catch (error) { 350 | expect(error.message).toEqual('timeout exceeded') 351 | } 352 | }) 353 | 354 | it('onLoad (async): is called and passed entire module', async () => { 355 | const onLoad = jest.fn() 356 | const mod = { __esModule: true, default: 'foo' } 357 | const asyncImport = Promise.resolve(mod) 358 | const { requireAsync } = requireModule(() => asyncImport, { 359 | onLoad, 360 | key: 'default' 361 | }) 362 | 363 | const props = { foo: 'bar' } 364 | await requireAsync(props) 365 | 366 | const info = { isServer: false, isSync: false } 367 | expect(onLoad).toBeCalledWith(mod, info, props) 368 | expect(onLoad).not.toBeCalledWith('foo', info, props) 369 | }) 370 | 371 | it('onLoad (sync): is called and passed entire module', async () => { 372 | const onLoad = jest.fn() 373 | const mod = { __esModule: true, default: 'foo' } 374 | const asyncImport = () => { 375 | throw new Error('ah') 376 | } 377 | 378 | global.__webpack_modules__ = { id: mod } 379 | global.__webpack_require__ = id => __webpack_modules__[id] 380 | 381 | const { requireSync } = requireModule(asyncImport, { 382 | onLoad, 383 | resolve: () => 'id', 384 | key: 'default' 385 | }) 386 | 387 | const props = { foo: 'bar' } 388 | requireSync(props) 389 | 390 | const info = { isServer: false, isSync: true } 391 | expect(onLoad).toBeCalledWith(mod, info, props) 392 | expect(onLoad).not.toBeCalledWith('foo', info, props) 393 | 394 | delete global.__webpack_require__ 395 | delete global.__webpack_modules__ 396 | }) 397 | }) 398 | -------------------------------------------------------------------------------- /__tests__/index.js: -------------------------------------------------------------------------------- 1 | // @noflow 2 | import path from 'path' 3 | import React from 'react' 4 | import renderer from 'react-test-renderer' 5 | 6 | import universal from '../src' 7 | import { flushModuleIds, flushChunkNames } from '../src/requireUniversalModule' 8 | 9 | import { 10 | createApp, 11 | createDynamicApp, 12 | createPath, 13 | createBablePluginApp, 14 | createDynamicBablePluginApp 15 | } from '../__test-helpers__/createApp' 16 | 17 | import { 18 | normalizePath, 19 | waitFor, 20 | Loading, 21 | Err, 22 | MyComponent, 23 | MyComponent2, 24 | createComponent, 25 | createDynamicComponent, 26 | createBablePluginComponent, 27 | createDynamicBablePluginComponent, 28 | dynamicBabelNodeComponent, 29 | createDynamicComponentAndOptions 30 | } from '../__test-helpers__' 31 | 32 | describe('async lifecycle', () => { 33 | it('loading', async () => { 34 | const asyncComponent = createComponent(400, MyComponent) 35 | const Component = universal(asyncComponent) 36 | 37 | const component1 = renderer.create() 38 | expect(component1.toJSON()).toMatchSnapshot() // initial 39 | 40 | await waitFor(200) 41 | expect(component1.toJSON()).toMatchSnapshot() // loading 42 | 43 | await waitFor(200) 44 | expect(component1.toJSON()).toMatchSnapshot() // loaded 45 | 46 | const component2 = renderer.create() 47 | expect(component2.toJSON()).toMatchSnapshot() // re-loaded 48 | }) 49 | 50 | it('error', async () => { 51 | const asyncComponent = createComponent(40, null) 52 | const Component = universal(asyncComponent) 53 | 54 | const component = renderer.create() 55 | expect(component.toJSON()).toMatchSnapshot() // initial 56 | 57 | await waitFor(20) 58 | expect(component.toJSON()).toMatchSnapshot() // loading 59 | 60 | await waitFor(20) 61 | expect(component.toJSON()).toMatchSnapshot() // errored 62 | }) 63 | 64 | it('timeout error', async () => { 65 | const asyncComponent = createComponent(40, null) 66 | const Component = universal(asyncComponent, { 67 | timeout: 10 68 | }) 69 | 70 | const component = renderer.create() 71 | expect(component.toJSON()).toMatchSnapshot() // initial 72 | 73 | await waitFor(20) 74 | 75 | expect(component.toJSON()).toMatchSnapshot() // error 76 | }) 77 | 78 | it('component unmounted: setState not called', async () => { 79 | const asyncComponent = createComponent(10, MyComponent) 80 | const Component = universal(asyncComponent) 81 | 82 | let instance 83 | const component = renderer.create( (instance = i)} />) 84 | 85 | instance.componentWillUnmount() 86 | await waitFor(20) 87 | 88 | // component will still be in loading state because setState is NOT called 89 | // since its unmounted. In reality, it won't be rendered anymore. 90 | expect(component.toJSON()).toMatchSnapshot() /*? component.toJSON() */ 91 | }) 92 | }) 93 | 94 | describe('props: all components receive props', () => { 95 | it('custom loading component', async () => { 96 | const asyncComponent = createComponent(40, MyComponent) 97 | const Component = universal(asyncComponent, { 98 | loading: Loading 99 | }) 100 | 101 | const component1 = renderer.create() 102 | expect(component1.toJSON()).toMatchSnapshot() // initial 103 | 104 | await waitFor(20) 105 | expect(component1.toJSON()).toMatchSnapshot() // loading 106 | 107 | await waitFor(20) 108 | expect(component1.toJSON()).toMatchSnapshot() // loaded 109 | 110 | const component2 = renderer.create() 111 | expect(component2.toJSON()).toMatchSnapshot() // re-loaded 112 | }) 113 | 114 | it('custom error component', async () => { 115 | const asyncComponent = createComponent(40, null) 116 | const Component = universal(asyncComponent, { 117 | error: Err 118 | }) 119 | 120 | const component1 = renderer.create() 121 | expect(component1.toJSON()).toMatchSnapshot() // initial 122 | 123 | await waitFor(20) 124 | expect(component1.toJSON()).toMatchSnapshot() // loading 125 | 126 | await waitFor(20) 127 | expect(component1.toJSON()).toMatchSnapshot() // Error! 128 | 129 | const component2 = renderer.create() 130 | expect(component2.toJSON()).toMatchSnapshot() // loading again.. 131 | }) 132 | 133 | it(' - also displays loading component', async () => { 134 | const asyncComponent = createComponent(40, MyComponent) 135 | const Component = universal(asyncComponent) 136 | 137 | const component1 = renderer.create() 138 | expect(component1.toJSON()).toMatchSnapshot() // initial 139 | 140 | await waitFor(50) 141 | expect(component1.toJSON()).toMatchSnapshot() // loading even though async component is available 142 | }) 143 | 144 | it(' - also displays error component', async () => { 145 | const asyncComponent = createComponent(40, MyComponent) 146 | const Component = universal(asyncComponent, { error: Err }) 147 | 148 | const component1 = renderer.create() 149 | expect(component1.toJSON()).toMatchSnapshot() // initial 150 | 151 | await waitFor(50) 152 | expect(component1.toJSON()).toMatchSnapshot() // error even though async component is available 153 | }) 154 | 155 | it('components passed as elements: loading', async () => { 156 | const asyncComponent = createComponent(40, ) 157 | const Component = universal(asyncComponent, { 158 | loading: 159 | }) 160 | 161 | const component1 = renderer.create() 162 | expect(component1.toJSON()).toMatchSnapshot() // initial 163 | 164 | await waitFor(20) 165 | expect(component1.toJSON()).toMatchSnapshot() // loading 166 | 167 | await waitFor(20) 168 | expect(component1.toJSON()).toMatchSnapshot() // loaded 169 | 170 | const component2 = renderer.create() 171 | expect(component2.toJSON()).toMatchSnapshot() // reload 172 | }) 173 | 174 | it('components passed as elements: error', async () => { 175 | const asyncComponent = createComponent(40, null) 176 | const Component = universal(asyncComponent, { 177 | error: 178 | }) 179 | 180 | const component1 = renderer.create() 181 | expect(component1.toJSON()).toMatchSnapshot() // initial 182 | 183 | await waitFor(20) 184 | expect(component1.toJSON()).toMatchSnapshot() // loading 185 | 186 | await waitFor(20) 187 | expect(component1.toJSON()).toMatchSnapshot() // Error! 188 | 189 | const component2 = renderer.create() 190 | expect(component2.toJSON()).toMatchSnapshot() // loading again... 191 | }) 192 | 193 | it('arguments/props passed to asyncComponent function for data-fetching', async () => { 194 | const asyncComponent = async (props, cb) => { 195 | // this is what you would actually be doing here: 196 | // const data = await fetch(`/path?key=${props.prop}`) 197 | // const value = await data.json() 198 | 199 | const value = props.prop 200 | const component = await Promise.resolve(
{value}
) 201 | return component 202 | } 203 | const Component = universal(asyncComponent, { 204 | key: null 205 | }) 206 | 207 | const component1 = renderer.create() 208 | expect(component1.toJSON()).toMatchSnapshot() // initial 209 | 210 | await waitFor(10) 211 | expect(component1.toJSON()).toMatchSnapshot() // loaded 212 | }) 213 | }) 214 | 215 | describe('server-side rendering', () => { 216 | it('es6: default export automatically resolved', async () => { 217 | const asyncComponent = createComponent(40, null) 218 | const Component = universal(asyncComponent, { 219 | path: path.join(__dirname, '../__fixtures__/component') 220 | }) 221 | 222 | const component = renderer.create() 223 | 224 | expect(component.toJSON()).toMatchSnapshot() // serverside 225 | }) 226 | 227 | it('es5: module.exports resolved', async () => { 228 | const asyncComponent = createComponent(40, null) 229 | const Component = universal(asyncComponent, { 230 | path: path.join(__dirname, '../__fixtures__/component.es5') 231 | }) 232 | 233 | const component = renderer.create() 234 | 235 | expect(component.toJSON()).toMatchSnapshot() // serverside 236 | }) 237 | }) 238 | 239 | describe('other options', () => { 240 | it('key (string): resolves export to value of key', async () => { 241 | const asyncComponent = createComponent(20, { fooKey: MyComponent }) 242 | const Component = universal(asyncComponent, { 243 | key: 'fooKey' 244 | }) 245 | 246 | const component = renderer.create() 247 | expect(component.toJSON()).toMatchSnapshot() // initial 248 | 249 | await waitFor(5) 250 | expect(component.toJSON()).toMatchSnapshot() // loading 251 | 252 | await waitFor(30) 253 | expect(component.toJSON()).toMatchSnapshot() // success 254 | }) 255 | 256 | it('key (function): resolves export to function return', async () => { 257 | const asyncComponent = createComponent(20, { fooKey: MyComponent }) 258 | const Component = universal(asyncComponent, { 259 | key: module => module.fooKey 260 | }) 261 | 262 | const component = renderer.create() 263 | expect(component.toJSON()).toMatchSnapshot() // initial 264 | 265 | await waitFor(5) 266 | expect(component.toJSON()).toMatchSnapshot() // loading 267 | 268 | await waitFor(20) 269 | expect(component.toJSON()).toMatchSnapshot() // success 270 | }) 271 | 272 | it('onLoad (async): is called and passed an es6 module', async () => { 273 | const onLoad = jest.fn() 274 | const mod = { __esModule: true, default: MyComponent } 275 | const asyncComponent = createComponent(40, mod) 276 | const Component = universal(asyncComponent, { onLoad }) 277 | 278 | const component = renderer.create() 279 | 280 | await waitFor(50) 281 | const info = { isServer: false, isSync: false } 282 | const props = { foo: 'bar' } 283 | expect(onLoad).toBeCalledWith(mod, info, props) 284 | 285 | expect(component.toJSON()).toMatchSnapshot() // success 286 | }) 287 | 288 | it('onLoad (async): is called and passed entire module', async () => { 289 | const onLoad = jest.fn() 290 | const mod = { __esModule: true, foo: MyComponent } 291 | const asyncComponent = createComponent(40, mod) 292 | const Component = universal(asyncComponent, { 293 | onLoad, 294 | key: 'foo' 295 | }) 296 | 297 | const component = renderer.create() 298 | 299 | await waitFor(50) 300 | const info = { isServer: false, isSync: false } 301 | expect(onLoad).toBeCalledWith(mod, info, {}) 302 | 303 | expect(component.toJSON()).toMatchSnapshot() // success 304 | }) 305 | 306 | it('onLoad (sync): is called and passed entire module', async () => { 307 | const onLoad = jest.fn() 308 | const asyncComponent = createComponent(40) 309 | const Component = universal(asyncComponent, { 310 | onLoad, 311 | key: 'default', 312 | path: path.join(__dirname, '..', '__fixtures__', 'component') 313 | }) 314 | 315 | renderer.create() 316 | 317 | expect(onLoad).toBeCalledWith( 318 | require('../__fixtures__/component'), 319 | { 320 | isServer: false, 321 | isSync: true 322 | }, 323 | {} 324 | ) 325 | }) 326 | 327 | it('minDelay: loads for duration of minDelay even if component ready', async () => { 328 | const asyncComponent = createComponent(40, MyComponent) 329 | const Component = universal(asyncComponent, { 330 | minDelay: 60 331 | }) 332 | 333 | const component = renderer.create() 334 | expect(component.toJSON()).toMatchSnapshot() // initial 335 | 336 | await waitFor(45) 337 | expect(component.toJSON()).toMatchSnapshot() // still loading 338 | 339 | await waitFor(30) 340 | expect(component.toJSON()).toMatchSnapshot() // loaded 341 | }) 342 | }) 343 | 344 | describe('SSR flushing: flushModuleIds() + flushChunkNames()', () => { 345 | it('babel', async () => { 346 | const App = createApp() 347 | 348 | flushModuleIds() // insure sets are empty: 349 | flushChunkNames() 350 | 351 | renderer.create() 352 | let paths = flushModuleIds().map(normalizePath) 353 | let chunkNames = flushChunkNames() 354 | 355 | expect(paths).toEqual(['/component', '/component2']) 356 | expect(chunkNames).toEqual(['component', 'component2']) 357 | 358 | renderer.create() 359 | paths = flushModuleIds().map(normalizePath) 360 | chunkNames = flushChunkNames() 361 | 362 | expect(paths).toEqual(['/component', '/component3']) 363 | expect(chunkNames).toEqual(['component', 'component3']) 364 | }) 365 | 366 | it('babel (babel-plugin)', async () => { 367 | const App = createBablePluginApp() 368 | 369 | flushModuleIds() // insure sets are empty: 370 | flushChunkNames() 371 | 372 | renderer.create() 373 | let paths = flushModuleIds().map(normalizePath) 374 | let chunkNames = flushChunkNames().map(normalizePath) 375 | 376 | expect(paths).toEqual(['/component', '/component2']) 377 | expect(chunkNames).toEqual(['/component', '/component2']) 378 | 379 | renderer.create() 380 | paths = flushModuleIds().map(normalizePath) 381 | chunkNames = flushChunkNames().map(normalizePath) 382 | 383 | expect(paths).toEqual(['/component', '/component3']) 384 | expect(chunkNames).toEqual(['/component', '/component3']) 385 | }) 386 | 387 | it('webpack', async () => { 388 | global.__webpack_require__ = path => __webpack_modules__[path] 389 | 390 | // modules stored by paths instead of IDs (replicates babel implementation) 391 | global.__webpack_modules__ = { 392 | [createPath('component')]: require(createPath('component')), 393 | [createPath('component2')]: require(createPath('component2')), 394 | [createPath('component3')]: require(createPath('component3')) 395 | } 396 | 397 | const App = createApp(true) 398 | 399 | flushModuleIds() // insure sets are empty: 400 | flushChunkNames() 401 | 402 | renderer.create() 403 | let paths = flushModuleIds().map(normalizePath) 404 | let chunkNames = flushChunkNames() 405 | 406 | expect(paths).toEqual(['/component', '/component2']) 407 | expect(chunkNames).toEqual(['component', 'component2']) 408 | 409 | renderer.create() 410 | paths = flushModuleIds().map(normalizePath) 411 | chunkNames = flushChunkNames() 412 | 413 | expect(paths).toEqual(['/component', '/component3']) 414 | expect(chunkNames).toEqual(['component', 'component3']) 415 | 416 | delete global.__webpack_require__ 417 | delete global.__webpack_modules__ 418 | }) 419 | 420 | it('webpack (babel-plugin)', async () => { 421 | global.__webpack_require__ = path => __webpack_modules__[path] 422 | 423 | // modules stored by paths instead of IDs (replicates babel implementation) 424 | global.__webpack_modules__ = { 425 | [createPath('component')]: require(createPath('component')), 426 | [createPath('component2')]: require(createPath('component2')), 427 | [createPath('component3')]: require(createPath('component3')) 428 | } 429 | 430 | const App = createBablePluginApp(true) 431 | 432 | flushModuleIds() // insure sets are empty: 433 | flushChunkNames() 434 | 435 | renderer.create() 436 | let paths = flushModuleIds().map(normalizePath) 437 | let chunkNames = flushChunkNames().map(normalizePath) 438 | 439 | expect(paths).toEqual(['/component', '/component2']) 440 | expect(chunkNames).toEqual(['/component', '/component2']) 441 | 442 | renderer.create() 443 | paths = flushModuleIds().map(normalizePath) 444 | chunkNames = flushChunkNames().map(normalizePath) 445 | 446 | expect(paths).toEqual(['/component', '/component3']) 447 | expect(chunkNames).toEqual(['/component', '/component3']) 448 | 449 | delete global.__webpack_require__ 450 | delete global.__webpack_modules__ 451 | }) 452 | 453 | it('babel: dynamic require', async () => { 454 | const App = createDynamicApp() 455 | 456 | flushModuleIds() // insure sets are empty: 457 | flushChunkNames() 458 | 459 | renderer.create() 460 | let paths = flushModuleIds().map(normalizePath) 461 | let chunkNames = flushChunkNames() 462 | 463 | expect(paths).toEqual(['/component', '/component2']) 464 | expect(chunkNames).toEqual(['component', 'component2']) 465 | 466 | renderer.create() 467 | paths = flushModuleIds().map(normalizePath) 468 | chunkNames = flushChunkNames() 469 | 470 | expect(paths).toEqual(['/component', '/component3']) 471 | expect(chunkNames).toEqual(['component', 'component3']) 472 | }) 473 | 474 | it('webpack: dynamic require', async () => { 475 | global.__webpack_require__ = path => __webpack_modules__[path] 476 | 477 | // modules stored by paths instead of IDs (replicates babel implementation) 478 | global.__webpack_modules__ = { 479 | [createPath('component')]: require(createPath('component')), 480 | [createPath('component2')]: require(createPath('component2')), 481 | [createPath('component3')]: require(createPath('component3')) 482 | } 483 | 484 | const App = createDynamicApp(true) 485 | 486 | flushModuleIds() // insure sets are empty: 487 | flushChunkNames() 488 | 489 | renderer.create() 490 | let paths = flushModuleIds().map(normalizePath) 491 | let chunkNames = flushChunkNames() 492 | 493 | expect(paths).toEqual(['/component', '/component2']) 494 | expect(chunkNames).toEqual(['component', 'component2']) 495 | 496 | renderer.create() 497 | paths = flushModuleIds().map(normalizePath) 498 | chunkNames = flushChunkNames() 499 | 500 | expect(paths).toEqual(['/component', '/component3']) 501 | expect(chunkNames).toEqual(['component', 'component3']) 502 | 503 | delete global.__webpack_require__ 504 | delete global.__webpack_modules__ 505 | }) 506 | 507 | it('babel: dynamic require (babel-plugin)', async () => { 508 | const App = createDynamicBablePluginApp() 509 | 510 | flushModuleIds() // insure sets are empty: 511 | flushChunkNames() 512 | 513 | renderer.create() 514 | let paths = flushModuleIds().map(normalizePath) 515 | let chunkNames = flushChunkNames() 516 | 517 | expect(paths).toEqual(['/component', '/component2']) 518 | expect(chunkNames).toEqual(['component', 'component2']) 519 | 520 | renderer.create() 521 | paths = flushModuleIds().map(normalizePath) 522 | chunkNames = flushChunkNames() 523 | 524 | expect(paths).toEqual(['/component', '/component3']) 525 | expect(chunkNames).toEqual(['component', 'component3']) 526 | }) 527 | 528 | it('webpack: dynamic require (babel-plugin)', async () => { 529 | global.__webpack_require__ = path => __webpack_modules__[path] 530 | 531 | // modules stored by paths instead of IDs (replicates babel implementation) 532 | global.__webpack_modules__ = { 533 | [createPath('component')]: require(createPath('component')), 534 | [createPath('component2')]: require(createPath('component2')), 535 | [createPath('component3')]: require(createPath('component3')) 536 | } 537 | 538 | const App = createDynamicBablePluginApp(true) 539 | 540 | flushModuleIds() // insure sets are empty: 541 | flushChunkNames() 542 | 543 | renderer.create() 544 | let paths = flushModuleIds().map(normalizePath) 545 | let chunkNames = flushChunkNames() 546 | 547 | expect(paths).toEqual(['/component', '/component2']) 548 | expect(chunkNames).toEqual(['component', 'component2']) 549 | 550 | renderer.create() 551 | paths = flushModuleIds().map(normalizePath) 552 | chunkNames = flushChunkNames() 553 | 554 | expect(paths).toEqual(['/component', '/component3']) 555 | expect(chunkNames).toEqual(['component', 'component3']) 556 | 557 | delete global.__webpack_require__ 558 | delete global.__webpack_modules__ 559 | }) 560 | }) 561 | 562 | describe('advanced', () => { 563 | it('dynamic requires (async)', async () => { 564 | const components = { MyComponent } 565 | const asyncComponent = createDynamicComponent(0, components) 566 | const Component = universal(asyncComponent) 567 | 568 | const component = renderer.create() 569 | await waitFor(5) 570 | 571 | expect(component.toJSON()).toMatchSnapshot() // success 572 | }) 573 | 574 | it('Component.preload: static preload method pre-fetches chunk', async () => { 575 | const components = { MyComponent } 576 | const asyncComponent = createDynamicComponent(40, components) 577 | const Component = universal(asyncComponent) 578 | 579 | Component.preload({ page: 'MyComponent' }) 580 | await waitFor(20) 581 | 582 | const component1 = renderer.create() 583 | 584 | expect(component1.toJSON()).toMatchSnapshot() // still loading... 585 | 586 | // without the preload, it still would be loading 587 | await waitFor(22) 588 | expect(component1.toJSON()).toMatchSnapshot() // success 589 | 590 | const component2 = renderer.create() 591 | expect(component2.toJSON()).toMatchSnapshot() // success 592 | }) 593 | 594 | it('Component.preload: static preload method hoists non-react statics', async () => { 595 | // define a simple component with static properties 596 | const FooComponent = props => ( 597 |
FooComponent {JSON.stringify(props)}
598 | ) 599 | FooComponent.propTypes = {} 600 | FooComponent.nonReactStatic = { foo: 'bar' } 601 | // prepare that component to be universally loaded 602 | const components = { FooComponent } 603 | const asyncComponent = createDynamicComponent(40, components) 604 | const Component = universal(asyncComponent) 605 | // wait for preload to finish 606 | await Component.preload({ page: 'FooComponent' }) 607 | // assert desired static is available 608 | expect(Component).not.toHaveProperty('propTypes') 609 | expect(Component).toHaveProperty('nonReactStatic') 610 | expect(Component.nonReactStatic).toBe(FooComponent.nonReactStatic) 611 | }) 612 | 613 | it('Component.preload: static preload method on node', async () => { 614 | const onLoad = jest.fn() 615 | const onErr = jest.fn() 616 | const opts = { testBabelPlugin: true } 617 | 618 | const Component = universal(dynamicBabelNodeComponent, opts) 619 | await Component.preload({ page: 'component' }).then(onLoad, onErr) 620 | 621 | expect(onErr).not.toHaveBeenCalled() 622 | 623 | const targetComponent = require(createPath('component')).default 624 | expect(onLoad).toBeCalledWith(targetComponent) 625 | }) 626 | 627 | it('promise passed directly', async () => { 628 | const asyncComponent = createComponent(0, MyComponent, new Error('ah')) 629 | 630 | const options = { 631 | chunkName: ({ page }) => page, 632 | error: ({ message }) =>
{message}
633 | } 634 | 635 | const Component = universal(asyncComponent(), options) 636 | 637 | const component = renderer.create() 638 | expect(component.toJSON()).toMatchSnapshot() // loading... 639 | 640 | await waitFor(2) 641 | expect(component.toJSON()).toMatchSnapshot() // loaded 642 | }) 643 | 644 | it('babel-plugin', async () => { 645 | const asyncComponent = createBablePluginComponent( 646 | 0, 647 | MyComponent, 648 | new Error('ah'), 649 | 'MyComponent' 650 | ) 651 | const options = { 652 | testBabelPlugin: true, 653 | chunkName: ({ page }) => page 654 | } 655 | 656 | const Component = universal(asyncComponent, options) 657 | 658 | const component = renderer.create() 659 | expect(component.toJSON()).toMatchSnapshot() // loading... 660 | 661 | await waitFor(2) 662 | expect(component.toJSON()).toMatchSnapshot() // loaded 663 | }) 664 | 665 | it('componentWillReceiveProps: changes component (dynamic require)', async () => { 666 | const components = { MyComponent, MyComponent2 } 667 | const asyncComponent = createDynamicBablePluginComponent(0, components) 668 | const options = { 669 | testBabelPlugin: true, 670 | chunkName: ({ page }) => page 671 | } 672 | 673 | const Component = universal(asyncComponent, options) 674 | 675 | class Container extends React.Component { 676 | render() { 677 | const page = (this.state && this.state.page) || 'MyComponent' 678 | return 679 | } 680 | } 681 | 682 | let instance 683 | const component = renderer.create( (instance = i)} />) 684 | expect(component.toJSON()).toMatchSnapshot() // loading... 685 | 686 | await waitFor(2) 687 | expect(component.toJSON()).toMatchSnapshot() // loaded 688 | 689 | instance.setState({ page: 'MyComponent2' }) 690 | 691 | expect(component.toJSON()).toMatchSnapshot() // loading... 692 | await waitFor(2) 693 | 694 | expect(component.toJSON()).toMatchSnapshot() // loaded 695 | }) 696 | 697 | it('componentWillReceiveProps: changes component (dynamic require) (no babel plugin)', async () => { 698 | const components = { MyComponent, MyComponent2 } 699 | const { load, options } = createDynamicComponentAndOptions(0, components) 700 | 701 | const Component = universal(load, options) 702 | 703 | class Container extends React.Component { 704 | render() { 705 | const page = (this.state && this.state.page) || 'MyComponent' 706 | return 707 | } 708 | } 709 | 710 | let instance 711 | const component = renderer.create( (instance = i)} />) 712 | expect(component.toJSON()).toMatchSnapshot() // loading... 713 | 714 | await waitFor(2) 715 | expect(component.toJSON()).toMatchSnapshot() // loaded 716 | 717 | instance.setState({ page: 'MyComponent2' }) 718 | 719 | expect(component.toJSON()).toMatchSnapshot() // loading... 720 | await waitFor(2) 721 | 722 | expect(component.toJSON()).toMatchSnapshot() // loaded 723 | }) 724 | }) 725 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Reactlandia Chat 3 | 4 | 5 | 6 | Edit React Universal Component on StackBlitz 7 | 8 | 9 | 10 | Edit React Universal Component on CodeSandBox 11 | 12 | 13 | 14 | # React Universal Component 15 | 16 |

17 | 18 | Version 19 | 20 | 21 | 22 | Build Status 23 | 24 | 25 | 26 | Coverage Status 27 | 28 | 29 | 30 | Downloads 31 | 32 | 33 | 34 | License 35 | 36 |

37 | 38 | 39 |

40 | 41 |

42 | 43 |

44 | 🍾🍾🍾 GIT CLONE 3.0 LOCAL DEMO 🚀🚀🚀 45 |

46 | 47 | - [React Universal Component](#react-universal-component) 48 | * [Intro](#intro) 49 | * [What makes Universal Rendering so painful](#what-makes-universal-rendering-so-painful) 50 | * [Installation](#installation) 51 | * [Other Packages You Will Need or Want](#other-packages-you-will-need-or-want) 52 | * [API and Options](#api-and-options) 53 | * [Flushing for SSR](#flushing-for-ssr) 54 | * [Preload](#preload) 55 | * [Static Hoisting](#static-hoisting) 56 | * [Props API](#props-api) 57 | * [Custom Rendering](#custom-rendering) 58 | * [Usage with CSS-in-JS libraries](#usage-with-css-in-js-libraries) 59 | * [Usage with two-stage rendering](#usage-with-two-stage-rendering) 60 | * [Universal Demo](#universal-demo) 61 | * [Contributing](#contributing) 62 | * [Tests](#tests) 63 | * [More from FaceySpacey](#more-from-faceyspacey-in-reactlandia) 64 | 65 | 66 | ## Intro 67 | 68 | For "power users" the traditional SPA is dead. If you're not universally rendering on the server, you're at risk of choking search engine visibility. As it stands, SEO and client-side rendering are not a match for SSR. Even though many search engines claim better SPA indexing, there are many caveats. **Server-side rendering matters: [JavaScript & SEO Backfire – A Hulu.com Case Study](https://www.elephate.com/blog/javascript-seo-backfire-hulu-com-case-study/)** 69 | 70 | 71 | The real problem has been **simultaneous SSR + Splitting**. If you've ever attempted such, *you know*. Here is a one-of-a-kind solution that brings it all together. 72 | 73 | *This is the final universal component for React you'll ever need, and it looks like this:* 74 | 75 | ```js 76 | import universal from 'react-universal-component' 77 | 78 | const UniversalComponent = universal(props => import(`./${props.page}`)) 79 | 80 | export default () => 81 |
82 | 83 |
84 | ``` 85 | 86 | It's made possible by our [PR to webpack](https://github.com/webpack/webpack/pull/5235) which built support for ```require.resolveWeak(`'./${page}`)```. Before it couldn't be dynamic--i.e. it supported one module, not a folder of modules. 87 | 88 | Dont forget to check out [webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks) - it provides the server with info about the render so it can deliver the right assets from the start. No additional async imports on first load. [It reduces time to interactive](https://developers.google.com/web/tools/lighthouse/audits/time-to-interactive) 89 | 90 | > DEFINITION: "Universal Rendering" is *simultaneous* SSR + Splitting, not trading one for the other. 91 | 92 | 93 |
Read more about Chunk Flushing 94 | 95 | Gone are the days of holding onto file hashes, manual loading conventions, and elaborate lookups on the server or client. You can frictionlessly support multiple components in one HoC as if imports weren't static. This seemingly small thing--we predict--will lead to universal rendering finally becoming commonplace. It's what a universal component for React is supposed to be. 96 | 97 | [webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks) brings it together server-side Ultimately that's the real foundation here and the most challenging part. Packages in the past like *React Loadable* did not address this aspect. They excelled at the SPA. In terms of universal rendering, but stumbled on provisioning whats required beyond the scope of knowing the module IDs that were rendered. There are a few extras to take into account. 98 | 99 | **Webpack Flush Chunks** ensures you serve all the chunks rendered on the server to the client in style. To be clear, it's been impossible until now. This is the first general solution to do it, and still the only one. You *must* use it in combination with React Universal Component to fulfill the universal code splitting dream. 100 |
101 | 102 | 103 | ## Installation 104 | 105 | ```yarn add react-universal-component``` 106 | 107 | *.babelrc:* 108 | ```js 109 | { 110 | "plugins": ["universal-import"] 111 | } 112 | ``` 113 | 114 | > For Typescript or environments without Babel, just copy what [babel-plugin-universal-import](https://github.com/faceyspacey/babel-plugin-universal-import) does. 115 | 116 | 117 | **Reactlandia Articles:** 118 | 119 | - **[code-cracked-for-ssr-plus-splitting-in-reactlandia](https://medium.com/@faceyspacey/code-cracked-for-code-splitting-ssr-in-reactlandia-react-loadable-webpack-flush-chunks-and-1a6b0112a8b8)** 🚀 120 | 121 | - **[announcing-react-universal-component-2-and-babel-plugin-universal-import](https://medium.com/faceyspacey/announcing-react-universal-component-2-0-babel-plugin-universal-import-5702d59ec1f4)** 🚀🚀🚀 122 | 123 | - [how-to-use-webpack-magic-comments-with-react-universal-component](https://medium.com/@faceyspacey/how-to-use-webpacks-new-magic-comment-feature-with-react-universal-component-ssr-a38fd3e296a) 124 | 125 | - [webpack-import-will-soon-fetch-js-and-css-heres-how-you-do-it-today](https://medium.com/faceyspacey/webpacks-import-will-soon-fetch-js-css-here-s-how-you-do-it-today-4eb5b4929852) 126 | 127 | ## Other Packages You Will Need or Want 128 | 129 | To be clear, you can get started with just the simple `HoC` shown at the top of the page, but to accomplish universal rendering, you will need to follow the directions in the *webpack-flush-chunks* package: 130 | 131 | - **[webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks)** 132 | 133 | And if you want CSS chunks *(which we highly recommend)*, you will need: 134 | - [extract-css-chunks-webpack-plugin](https://github.com/faceyspacey/extract-css-chunks-webpack-plugin) 135 | 136 | 137 | ## API and Options 138 | 139 | 140 | ```js 141 | universal(asyncComponent, options) 142 | ``` 143 | 144 | **asyncComponent:** 145 | - ```props => import(`./${props.page}`)``` 146 | - `import('./Foo')` *// doesn't need to be wrapped in a function when using the babel plugin!* 147 | - `(props, cb) => require.ensure([], require => cb(null, require('./Foo')))` 148 | 149 | The first argument can be a function that returns a promise, a promise itself, or a function that takes a node-style callback. The most powerful and popular is a function that takes props as an argument. 150 | 151 | **Options (all are optional):** 152 | 153 | - `loading`: LoadingComponent, -- *default: a simple one is provided for you* 154 | - `error`: ErrorComponent, -- *default: a simple one is provided for you* 155 | - `key`: `'foo'` || `module => module.foo` -- *default: `default` export in ES6 and `module.exports` in ES5* 156 | - `timeout`: `15000` -- *default* 157 | - `onError`: `(error, { isServer }) => handleError(error, isServer) 158 | - `onLoad`: `(module, { isSync, isServer }, props) => do(module, isSync, isServer, props)` 159 | - `minDelay`: `0` -- *default* 160 | - `alwaysDelay`: `false` -- *default* 161 | - `loadingTransition`: `true` -- *default* 162 | - `ignoreBabelRename`: `false` -- *default* 163 | 164 | - `render`: `(props, module, isLoading, error) => ` -- *default*: the default rendering logic is roughly equivalent to the following. 165 | ```js 166 | render: (props, Mod, isLoading, error) => { 167 | if (isLoading) return 168 | else if (error) return 169 | else if (Mod) return 170 | return 171 | } 172 | ``` 173 | 174 | **In Depth:** 175 | > All components can be classes/functions or elements (e.g: `Loading` or ``) 176 | 177 | - `loading` is the component class or function corresponding to your stateless component that displays while the primary import is loading. While testing out this package, you can leave it out as a simple default one is used. 178 | 179 | - `error` similarly is the component that displays if there are any errors that occur during your aynschronous import. While testing out this package, you can leave it out as a simple default one is used. 180 | 181 | - `key` lets you specify the export from the module you want to be your component if it's not `default` in ES6 or `module.exports` in ES5. It can be a string corresponding to the export key, or a function that's passed the entire module and returns the export that will become the component. 182 | 183 | - `timeout` allows you to specify a maximum amount of time before the `error` component is displayed. The default is 15 seconds. 184 | 185 | 186 | - `onError` is a callback called if async imports fail. It does not apply to sync requires. 187 | 188 | - `onLoad` is a callback function that receives the *entire* module. It allows you to export and put to use things other than your `default` component export, like reducers, sagas, etc. E.g: 189 | ```js 190 | onLoad: (module, info, props) => { 191 | props.store.replaceReducer({ ...otherReducers, foo: module.fooReducer }) 192 | 193 | // if a route triggered component change, new reducers needs to reflect it 194 | props.store.dispatch({ type: 'INIT_ACTION_FOR_ROUTE', payload: { param: props.param } }) 195 | } 196 | ```` 197 | **As you can see we have thought of everything you might need to really do code-splitting right (we have real apps that use this stuff).** `onLoad` is fired directly before the component is rendered so you can setup any reducers/etc it depends on. Unlike the `onAfter` prop, this *option* to the `universal` *HOC* is only fired the first time the module is received. *Also note*: it will fire on the server, so do `if (!isServer)` if you have to. But also keep in mind you will need to do things like replace reducers on both the server + client for the imported component that uses new reducers to render identically in both places. 198 | 199 | - `minDelay` is essentially the minimum amount of time the `loading` component will always show for. It's good for enforcing silky smooth animations, such as during a 500ms sliding transition. It insures the re-render won't happen until the animation is complete. It's often a good idea to set this to something like 300ms even if you don't have a transition, just so the loading spinner shows for an appropriate amount of time without jank. 200 | 201 | - `alwaysDelay` is a boolean you can set to true (*default: false*) to guarantee the `minDelay` is always used (i.e. even when components cached from previous imports and therefore synchronously and instantly required). This can be useful for guaranteeing animations operate as you want without having to wire up other components to perform the task. *Note: this only applies to the client when your `UniversalComponent` uses dynamic expressions to switch between multiple components.* 202 | 203 | - `loadingTransition` when set to `false` allows you to keep showing the current component when the `loading` component would otherwise show during transitions from one component to the next. 204 | - `ignoreBabelRename` is by default set to `false` which allows the plugin to attempt and name the dynamically imported chunk (replacing `/` with `-`). In more advanced scenarios where more granular control is required over the webpack chunk name, you should set this to `true` in addition to providing a function to `chunkName` to control chunk naming. 205 | 206 | - `render` overrides the default rendering logic. This option enables some interesting and useful usage of this library. Please refer to the [Custom Rendering](#custom-rendering) section. 207 | 208 | ## What makes Universal Rendering so painful 209 | 210 | One wouldn't expect it to be. Sadly the SSR part of react hasn't been as innovative as the CSR side. 211 | 212 | If you didn't know how much of a pain in the ass *universal rendering* has been, check this quote from the **React Router** docs: 213 | 214 | ![require-universal-component react-router quote](./react-router-quote.png) 215 | 216 | ## Flushing for SSR 217 | 218 | Below is the most important thing on this page. It's a quick example of the connection between this package and [webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks): 219 | 220 | ```js 221 | import { clearChunks, flushChunkNames } from 'react-universal-component/server' 222 | import flushChunks from 'webpack-flush-chunks' 223 | import ReactDOM from 'react-dom/server' 224 | 225 | export default function serverRender(req, res) => { 226 | clearChunks() 227 | const app = ReactDOM.renderToString() 228 | const { js, styles, cssHash } = flushChunks(webpackStats, { 229 | chunkNames: flushChunkNames() 230 | }) 231 | 232 | res.send(` 233 | 234 | 235 | 236 | ${styles} 237 | 238 | 239 |
${app}
240 | ${cssHash} 241 | ${js} 242 | 243 | 244 | `) 245 | ``` 246 | 247 | > NOTE: this requires that the bundling and rendering happen within the same context. The module, react-universal-component/server holds a global cache of all the universal components that are rendered and makes them available via `flushChunkNames` 248 | 249 | If you build step and your render step are separate (i.e. using a static site generator like `react-static`) we can use a Provider type component to locate the components that should be included on the client. This is not the recommended use of locating chunk names and only should be used when absolutely necessary. It uses React's context functionality to pass the `report` function to react-universal-component. 250 | 251 | ```js 252 | import { ReportChunks } from 'react-universal-component' 253 | import flushChunks from 'webpack-flush-chunks' 254 | import ReactDOM from 'react-dom/server' 255 | 256 | function renderToHtml () => { 257 | let chunkNames = [] 258 | const appHtml = 259 | ReactDOM.renderToString( 260 | chunkNames.push(chunkName)}> 261 | 262 | , 263 | ), 264 | ) 265 | 266 | const { scripts } = flushChunks(webpackStats, { 267 | chunkNames, 268 | }) 269 | 270 | return appHtml 271 | } 272 | ``` 273 | 274 | 275 | ## Preload 276 | 277 | You can preload the async component if there's a likelihood it will show soon: 278 | 279 | ```js 280 | import universal from 'react-universal-component' 281 | 282 | const UniversalComponent = universal(import('./Foo')) 283 | 284 | export default class MyComponent extends React.Component { 285 | componentWillMount() { 286 | UniversalComponent.preload() 287 | } 288 | 289 | render() { 290 | return
{this.props.visible && }
291 | } 292 | } 293 | ``` 294 | 295 | ## Static Hoisting 296 | 297 | If your imported component has static methods like this: 298 | 299 | ```js 300 | export default class MyComponent extends React.Component { 301 | static doSomething() {} 302 | render() {} 303 | } 304 | ``` 305 | 306 | Then this will work: 307 | 308 | ```js 309 | const MyUniversalComponent = universal(import('./MyComponent')) 310 | 311 | // render it 312 | 313 | 314 | // call this only after you're sure it has loaded 315 | MyUniversalComponent.doSomething() 316 | 317 | // If you are not sure if the component has loaded or rendered, call preloadWeak(). 318 | // This will attempt to hoist and return the inner component, 319 | // but only if it can be loaded synchronously, otherwise null will be returned. 320 | const InnerComponent = MyUniversalComponent.preloadWeak() 321 | if (InnerComponent) { 322 | InnerComponent.doSomething() 323 | } 324 | ``` 325 | > NOTE: for imports using dynamic expressions, conflicting methods will be overwritten by the current component 326 | 327 | > NOTE: preloadWeak() will not cause network requests, which means that if the component has not loaded, it will return null. Use it only when you need to retrieve and hoist the wrapped component before rendering. Calling preloadWeak() on your server will ensure that all statics are hoisted properly. 328 | 329 | ## Props API 330 | 331 | - `isLoading: boolean` 332 | - `error: new Error` 333 | - `onBefore`: `({ isMount, isSync, isServer }) => doSomething(isMount, isSync, isServer)` 334 | - `onAfter`: `({ isMount, isSync, isServer }, Component) => doSomething(Component, isMount, etc)` 335 | - `onError`: `error => handleError(error)` 336 | 337 | ### `isLoading` + `error`: 338 | You can pass `isLoading` and `error` props to the resulting component returned from the `universal` HoC. This has the convenient benefit of allowing you to continue to show the ***same*** `loading` component (or trigger the ***same*** `error` component) that is shown while your async component loads *AND* while any data-fetching may be occuring in a parent HoC. That means less jank from unnecessary re-renders, and less work (DRY). 339 | 340 | Here's an example using Apollo: 341 | 342 | ```js 343 | const UniversalUser = universal(import('./User')) 344 | 345 | const User = ({ loading, error, user }) => 346 |
347 | 348 |
349 | 350 | export default graphql(gql` 351 | query CurrentUser { 352 | user { 353 | id 354 | name 355 | } 356 | } 357 | `, { 358 | props: ({ ownProps, data: { loading, error, user } }) => ({ 359 | loading, 360 | error, 361 | user, 362 | }), 363 | })(User) 364 | ``` 365 | > If it's not clear, the ***same*** `loading` component will show while both async aspects load, without flinching/re-rendering. And perhaps more importantly **they will be run in parallel**. 366 | 367 | ### `onBefore` + `onAfter`: 368 | 369 | `onBefore/After` are callbacks called before and after the wrapped component loads/changes on both `componentWillMount` and `componentWillReceiveProps`. This enables you to display `loading` indicators elsewhere in the UI. 370 | 371 | If the component is already cached or you're on the server, they will both be called ***back to back synchronously***. They're both still called in this case for consistency. And they're both called before re-render to trigger the least amount of renders. Each receives an `info` object, giving you full flexibility in terms of deciding what to do. Here are the keys on it: 372 | 373 | - `isMount` *(whether the component just mounted)* 374 | - `isSync` *(whether the imported component is already available from previous usage and required synchronsouly)* 375 | - `isServer` *(very rarely will you want to do stuff on the server; note: server will always be sync)* 376 | 377 | `onAfter` is also passed a second argument containing the imported `Component`, which you can use to do things like call its static methods. 378 | 379 | 380 | ```js 381 | const UniversalComponent = universal(props => import(`./${props.page}`)) 382 | 383 | const MyComponent = ({ dispatch, isLoading }) => 384 |
385 | {isLoading &&
loading...
} 386 | 387 | !isSync && dispatch({ type: 'LOADING', true })} 390 | onAfter={({ isSync }, Component) => !isSync && dispatch({ type: 'LOADING', false })} 391 | /> 392 |
393 | ``` 394 | 395 | > Keep in mind if you call `setState` within these callbacks and they are called during `componentWillMount`, the `state` change will have no effect for that render. This is because the component is already in the middle of being rendered within the parent on which `this.setState` will be called. You can use *Redux* to call `dispatch` and that will affect child components. However, it's best to use this primarily for setting up and tearing down loading state on the client, and nothing more. If you chose to use them on the server, make sure the client renders the same thing on first load or you will have checksum mismatches. One good thing is that if you use `setState`, it in fact won't cause checksum mismatches since it won't be called on the server or the first render on the client. It will be called on an instant subsequent render on the client and helpfully display errors where it counts. The same won't apply with `dispatch` which can affect children components, and therefore could lead to rendering different things on each side. 396 | 397 | ### `onError` 398 | 399 | `onError` is similar to the `onError` static option, except it operates at the component level. Therefore you can bind to `this` of the parent component and call `this.setState()` or `this.props.dispatch()`. Again, it's use case is for when you want to show error information elsewhere in the UI besides just the place that the universal component would otherwise render. 400 | 401 | **The reality is just having the `` as the only placeholder where you can show loading and error information is very limiting and not good enough for real apps. Hence these props.** 402 | 403 | ## Custom Rendering 404 | 405 | This library supports custom rendering so that you can define rendering logic that best suits your own need. This feature has also enabled some interesting and useful usage of this library. 406 | 407 | For example, in some static site generation setup, data are loaded as JavaScript modules, instead of being fetched from an API. In this case, the async component that you are loading is not a React component, but an JavaScript object. To better illustrate this use case, suppose that there are some data modules `pageData1.js`, `pageData2.js`, ... in the `src/data` folder. Each of them corresponds to a page. 408 | ```js 409 | // src/data/pageDataX.js 410 | export default { title: 'foo', content: 'bar' } 411 | ``` 412 | 413 | All of the `pageDataX.js` files will be rendered with the `` component below. You wouldn't want to create `Page1.jsx`, `Page2.jsx`, ... just to support the async loading of each data item. Instead, you can just define a single `Page` component as follows. 414 | ```js 415 | const Page = ({ title, content }) =>
{title}
{content}
416 | ``` 417 | 418 | And define your custom rendering logic. 419 | ```js 420 | // src/components/AsyncPage.jsx 421 | import universal from 'react-universal-component' 422 | 423 | const AsyncPage = universal(props => import(`../data/${props.pageDataId}`), { 424 | render: (props, mod) => 425 | }) 426 | 427 | export default AsyncPage 428 | ``` 429 | 430 | Now, with a `pageId` props provided by your router, or whatever data sources, you can load your data synchronsouly on the server side (and on the client side for the initial render), and asynchronously on the client side for the subsequent render. 431 | ```js 432 | // Usage 433 | const BlogPost = (props) => 434 |
435 | 436 |
437 | ``` 438 | 439 | ## Usage with CSS-in-JS libraries 440 | 441 | flushChunkNames relies on renderToString's synchronous execution to keep track of dynamic chunks. This is the same strategy used by CSS-in-JS frameworks to extract critical CSS for first render. 442 | 443 | To use these together, simply wrap the CSS library's callback with `clearChunks()` and `flushChunkNames()`: 444 | 445 | ### Example with Aphrodite 446 | 447 | ```js 448 | import { StyleSheetServer } from 'aphrodite' 449 | import { clearChunks, flushChunkNames } from "react-universal-component/server" 450 | import ReactDOM from 'react-dom/server' 451 | 452 | clearChunks() 453 | // similar for emotion, aphodite, glamor, glamorous 454 | const { html, css} = StyleSheetServer.renderStatic(() => { 455 | return ReactDOM.renderToString(app) 456 | }) 457 | const chunkNames = flushChunkNames() 458 | 459 | // res.send template 460 | ``` 461 | 462 | Just like CSS-in-JS libraries, this library is not compatible with asynchronous renderToString replacements, such as react-dom-stream. Using the two together will give unpredictable results! 463 | 464 | ## Usage with two-stage rendering 465 | 466 | Some data-fetching libraries require an additional step which walks the render tree (react-apollo, isomorphic-relay, react-tree-walker). These are compatible, as long as chunks are cleared after the collection step. 467 | 468 | ### Example with react-apollo and Aphrodite 469 | 470 | ```js 471 | import { getDataFromTree } from "react-apollo" 472 | import { StyleSheetServer } from 'aphrodite' 473 | import { clearChunks, flushChunkNames } from "react-universal-component/server" 474 | import ReactDOM from 'react-dom/server' 475 | 476 | const app = ( 477 | 478 | 479 | 480 | ) 481 | 482 | // If clearChunks() is run here, getDataFromTree() can cause chunks to leak between requests. 483 | getDataFromTree(app).then(() => { 484 | const initialState = client.cache.extract() 485 | 486 | // This is safe. 487 | clearChunks() 488 | const { html, css} = StyleSheetServer.renderStatic(() => { 489 | return ReactDOM.renderToString(app) 490 | }) 491 | const chunkNames = flushChunkNames() 492 | 493 | // res.send template 494 | }) 495 | ``` 496 | 497 | ## Universal Demo 498 | 🍾🍾🍾 **[faceyspacey/universal-demo](https://github.com/faceyspacey/universal-demo)** 🚀🚀🚀 499 | 500 | ```bash 501 | git clone https://github.com/faceyspacey/universal-demo.git 502 | cd universal-demo 503 | yarn 504 | yarn start 505 | ``` 506 | 507 | ## Contributing 508 | 509 | We use [commitizen](https://github.com/commitizen/cz-cli), so run `npm run cm` to make commits. A command-line form will appear, requiring you answer a few questions to automatically produce a nicely formatted commit. Releases, semantic version numbers, tags, changelogs and publishing to NPM will automatically be handled based on these commits thanks to [semantic-release](https://github.com/semantic-release/semantic-release). Be good. 510 | 511 | 512 | ## Tests 513 | 514 | Reviewing a module's tests are a great way to get familiar with it. It's direct insight into the capabilities of the given module (if the tests are thorough). What's even better is a screenshot of the tests neatly organized and grouped (you know the whole "a picture says a thousand words" thing). 515 | 516 | Below is a screenshot of this module's tests running in [Wallaby](https://wallabyjs.com) *("An Integrated Continuous Testing Tool for JavaScript")* which everyone in the React community should be using. It's fantastic and has taken my entire workflow to the next level. It re-runs your tests on every change along with comprehensive logging, bi-directional linking to your IDE, in-line code coverage indicators, **and even snapshot comparisons + updates for Jest!** I requestsed that feature by the way :). It's basically a substitute for live-coding that inspires you to test along your journey. 517 | 518 | ![require-universal-module wallaby tests screenshot](./tests-screenshot-1.png) 519 | ![require-universal-module wallaby tests screenshot](./tests-screenshot-2.png) 520 | 521 | ## More from FaceySpacey in Reactlandia 522 | - [redux-first-router](https://github.com/faceyspacey/redux-first-router). It's made to work perfectly with *Universal*. Together they comprise our *"frameworkless"* Redux-based approach to what Next.js does (splitting, SSR, prefetching, routing). 523 | --------------------------------------------------------------------------------