From 10c8a17bd586f496728c953e580e77d6d5cd8cb1 Mon Sep 17 00:00:00 2001 From: James Gillmore Date: Tue, 4 Jul 2017 12:15:35 -0700 Subject: [PATCH] feat($newVersion): add new babel plugin so you only call import(), support for 2-file imports (js+cs --- .babelrc | 2 +- .eslintrc.js | 7 +- .flowconfig | 1 + __fixtures__/component.js | 2 + __fixtures__/es5.js | 3 + __fixtures__/es6.js | 2 + __test-helpers__/createApp.js | 93 +++++-- __test-helpers__/index.js | 71 ++++- __tests__/__snapshots__/index.js.snap | 79 +++++- __tests__/index.js | 368 ++++++++++++++++++++---- __tests__/requireUniversalModule.js | 385 ++++++++++++++++++++++++++ __tests__/utils.js | 86 ++++++ package.json | 6 +- server.js | 4 +- src/flowTypes.js | 113 ++++++++ src/index.js | 132 +++++---- src/requireUniversalModule.js | 205 ++++++++++++++ src/utils.js | 88 ++++++ wallaby.js | 3 +- yarn.lock | 57 +--- 20 files changed, 1512 insertions(+), 195 deletions(-) create mode 100644 __fixtures__/es5.js create mode 100644 __fixtures__/es6.js create mode 100644 __tests__/requireUniversalModule.js create mode 100644 __tests__/utils.js create mode 100644 src/flowTypes.js create mode 100644 src/requireUniversalModule.js create mode 100644 src/utils.js diff --git a/.babelrc b/.babelrc index c4506bd..ba94a3f 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,4 @@ { "presets": ["es2015", "react", "stage-0"], - "plugins": ["transform-flow-strip-types"] + "plugins": ["universal-import", "transform-flow-strip-types"] } diff --git a/.eslintrc.js b/.eslintrc.js index e6b2741..23293d4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -48,19 +48,20 @@ module.exports = { 'no-underscore-dangle': 0, 'no-plusplus': 0, 'new-parens': 0, + 'global-require': 0, camelcase: 1, 'prefer-template': 1, 'react/no-array-index-key': 1, - 'global-require': 1, 'react/jsx-indent': 1, 'dot-notation': 1, 'import/no-named-default': 1, 'no-unused-vars': 1, 'import/no-unresolved': 1, - 'flowtype/no-weak-types': 1, + 'flowtype/no-weak-types': 0, + camelcase: 0, + 'import/no-dynamic-require': 0, 'consistent-return': 1, 'no-empty': 1, - 'import/no-dynamic-require': 1, 'no-return-assign': 1, semi: [2, 'never'], 'no-console': [2, { allow: ['warn', 'error'] }], diff --git a/.flowconfig b/.flowconfig index 0859002..c22ab80 100644 --- a/.flowconfig +++ b/.flowconfig @@ -17,4 +17,5 @@ module.system=haste suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe suppress_comment=\\(.\\|\n\\)*\\$FlowIssue +suppress_comment=\\(.\\|\n\\)*\\$FlowIgnore suppress_comment=\\(.\\|\n\\)*\\$FlowGlobal diff --git a/__fixtures__/component.js b/__fixtures__/component.js index 58d3c18..bbc5c82 100644 --- a/__fixtures__/component.js +++ b/__fixtures__/component.js @@ -1,3 +1,5 @@ import React from 'react' export default () =>
fixture1
+ +export const foo = 'bar' diff --git a/__fixtures__/es5.js b/__fixtures__/es5.js new file mode 100644 index 0000000..010ab2a --- /dev/null +++ b/__fixtures__/es5.js @@ -0,0 +1,3 @@ +module.exports = { + foo: 'bar-es5' +} diff --git a/__fixtures__/es6.js b/__fixtures__/es6.js new file mode 100644 index 0000000..a9dc04d --- /dev/null +++ b/__fixtures__/es6.js @@ -0,0 +1,2 @@ +export default 'hello' +export const foo = 'bar' diff --git a/__test-helpers__/createApp.js b/__test-helpers__/createApp.js index 722f825..cab5dfd 100644 --- a/__test-helpers__/createApp.js +++ b/__test-helpers__/createApp.js @@ -1,27 +1,88 @@ import path from 'path' import React from 'react' -import { createComponent } from './index' -import universalComponent from '../src' +import { + createComponent, + createBablePluginComponent, + createDynamicBablePluginComponent +} from './index' +import universal from '../src' export const createPath = name => path.join(__dirname, '../__fixtures__', name) -export default isWebpack => { - const importAsync = createComponent(40, null, new Error('test error')) +export const createApp = isWebpack => { + const importAsync = createComponent(40) const create = name => - universalComponent(importAsync, { - path: path.join(__dirname, '..', '__fixtures__', name), - chunkName: name, - resolve: isWebpack && (() => createPath(name)) + universal(importAsync, { + path: createPath(name), + resolve: isWebpack && createPath(name), + chunkName: name }) - const Loadable1 = create('component') - const Loadable2 = create('component2') - const Loadable3 = create('component3') + const Component1 = create('component') + const Component2 = create('component2') + const Component3 = create('component3') return props => -
- {props.one ? : null} - {props.two ? : null} - {props.three ? : null} -
+ (
+ {props.one ? : null} + {props.two ? : null} + {props.three ? : null} +
) +} + +export const createDynamicApp = isWebpack => { + const importAsync = createComponent(40) + const Component = universal(importAsync, { + path: ({ page }) => createPath(page), + chunkName: ({ page }) => page, + resolve: isWebpack && (({ page }) => createPath(page)) + }) + + return props => + (
+ {props.one ? : null} + {props.two ? : null} + {props.three ? : null} +
) +} + +export const createBablePluginApp = isWebpack => { + const create = name => { + const importAsync = createBablePluginComponent( + 40, + null, + new Error('test error'), + createPath(name) + ) + return universal(importAsync, { testBabelPlugin: true }) + } + + const Component1 = create('component') + const Component2 = create('component2') + const Component3 = create('component3') + + return props => + (
+ {props.one ? : null} + {props.two ? : null} + {props.three ? : null} +
) +} + +export const createDynamicBablePluginApp = isWebpack => { + const create = name => { + const importAsync = createDynamicBablePluginComponent() + return universal(importAsync, { testBabelPlugin: true }) + } + + const Component1 = create('component') + const Component2 = create('component2') + const Component3 = create('component3') + + return props => + (
+ {props.one ? : null} + {props.two ? : null} + {props.three ? : null} +
) } diff --git a/__test-helpers__/index.js b/__test-helpers__/index.js index 82c6dd0..7939d4e 100644 --- a/__test-helpers__/index.js +++ b/__test-helpers__/index.js @@ -1,21 +1,80 @@ +import path from 'path' import React from 'react' +// fake delay so we can test different stages of async loading lifecycle +export const waitFor = ms => new Promise(resolve => setTimeout(resolve, ms)) + // normalize the required path so tests pass in all environments export const normalizePath = path => path.split('__fixtures__')[1] -// fake delay so we can test different stages of async loading lifecycle -export const waitFor = ms => new Promise(resolve => setTimeout(resolve, ms)) +export const createPath = name => path.join(__dirname, '../__fixtures__', name) export const Loading = props =>

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

export const Err = props =>

Error! {JSON.stringify(props)}

export const MyComponent = props =>

MyComponent {JSON.stringify(props)}

+export const MyComponent2 = props =>

MyComponent {JSON.stringify(props)}

-export const createComponent = (delay, Component, error) => async () => { +export const createComponent = ( + delay, + Component, + error = new Error('test error') +) => async () => { await waitFor(delay) + if (Component) return Component + throw error +} - if (Component) { - return Component +export const createDynamicComponent = ( + delay, + components, + error = new Error('test error') +) => async (props, tools) => { + await waitFor(delay) + const Component = components[props.page] + if (Component) return Component + throw error +} + +export const createBablePluginComponent = ( + delay, + Component, + error = new Error('test error'), + name +) => { + const asyncImport = async () => { + await waitFor(delay) + if (Component) return Component + throw error } - throw error + return { + chunkName: () => name, + path: () => name, + resolve: () => name, + load: () => Promise.all([asyncImport()]), + id: name, + file: `${name}.js` + } +} + +export const createDynamicBablePluginComponent = ( + delay, + components, + error = new Error('test error') +) => { + const asyncImport = async page => { + await waitFor(delay) + const Component = components[page] + if (Component) return Component + throw error + } + + return ({ page }) => ({ + chunkName: () => page, + path: () => createPath(page), + resolve: () => createPath(page), + load: () => Promise.all([asyncImport(page)]), + id: page, + file: `${page}.js` + }) } diff --git a/__tests__/__snapshots__/index.js.snap b/__tests__/__snapshots__/index.js.snap index 26f30e8..f4e45ac 100644 --- a/__tests__/__snapshots__/index.js.snap +++ b/__tests__/__snapshots__/index.js.snap @@ -1,19 +1,78 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Component.preload: static preload method pre-fetches chunk 1`] = ` +exports[`advanced Component.preload: static preload method pre-fetches chunk 1`] = `
Loading...
`; -exports[`Component.preload: static preload method pre-fetches chunk 2`] = ` +exports[`advanced Component.preload: static preload method pre-fetches chunk 2`] = `

MyComponent {}

`; -exports[`Component.preload: static preload method pre-fetches chunk 3`] = ` +exports[`advanced Component.preload: static preload method pre-fetches chunk 3`] = ` +

+ MyComponent + {} +

+`; + +exports[`advanced babel-plugin 1`] = ` +
+ Loading... +
+`; + +exports[`advanced babel-plugin 2`] = ` +

+ MyComponent + {} +

+`; + +exports[`advanced componentWillReceiveProps: changes component (dynamic require) 1`] = ` +
+ Loading... +
+`; + +exports[`advanced componentWillReceiveProps: changes component (dynamic require) 2`] = ` +

+ MyComponent + {"page":"MyComponent"} +

+`; + +exports[`advanced componentWillReceiveProps: changes component (dynamic require) 3`] = ` +
+ Loading... +
+`; + +exports[`advanced componentWillReceiveProps: changes component (dynamic require) 4`] = ` +

+ MyComponent + {"page":"MyComponent2"} +

+`; + +exports[`advanced dynamic requires (async) 1`] = ` +

+ MyComponent + {"page":"MyComponent"} +

+`; + +exports[`advanced promise passed directly 1`] = ` +
+ Loading... +
+`; + +exports[`advanced promise passed directly 2`] = `

MyComponent {} @@ -139,6 +198,20 @@ exports[`other options minDelay: loads for duration of minDelay even if componen

`; +exports[`other options onLoad (async): is called and passed an es6 module 1`] = ` +

+ MyComponent + {} +

+`; + +exports[`other options onLoad (async): is called and passed entire module 1`] = ` +

+ MyComponent + {} +

+`; + exports[`props: all components receive props - also displays error component 1`] = `

Error! diff --git a/__tests__/index.js b/__tests__/index.js index 8398155..a0cf1b1 100644 --- a/__tests__/index.js +++ b/__tests__/index.js @@ -3,23 +3,34 @@ import path from 'path' import React from 'react' import renderer from 'react-test-renderer' -import universalComponent from '../src' -import { flushModuleIds, flushChunkNames } from '../server' +import universal from '../src' +import { flushModuleIds, flushChunkNames } from '../src/requireUniversalModule' + +import { + createApp, + createDynamicApp, + createPath, + createBablePluginApp, + createDynamicBablePluginApp +} from '../__test-helpers__/createApp' -import createApp, { createPath } from '../__test-helpers__/createApp' import { normalizePath, waitFor, Loading, Err, MyComponent, - createComponent + MyComponent2, + createComponent, + createDynamicComponent, + createBablePluginComponent, + createDynamicBablePluginComponent } from '../__test-helpers__' describe('async lifecycle', () => { it('loading', async () => { const asyncComponent = createComponent(40, MyComponent) - const Component = universalComponent(asyncComponent) + const Component = universal(asyncComponent) const component1 = renderer.create() expect(component1.toJSON()).toMatchSnapshot() // initial @@ -31,13 +42,12 @@ describe('async lifecycle', () => { expect(component1.toJSON()).toMatchSnapshot() // loaded const component2 = renderer.create() - - expect(component2.toJSON()).toMatchSnapshot() // reload + expect(component2.toJSON()).toMatchSnapshot() // re-loaded }) it('error', async () => { - const asyncComponent = createComponent(40, null, new Error('test error')) - const Component = universalComponent(asyncComponent) + const asyncComponent = createComponent(40, null) + const Component = universal(asyncComponent) const component = renderer.create() expect(component.toJSON()).toMatchSnapshot() // initial @@ -50,8 +60,8 @@ describe('async lifecycle', () => { }) it('timeout error', async () => { - const asyncComponent = createComponent(40, null, new Error('test error')) - const Component = universalComponent(asyncComponent, { + const asyncComponent = createComponent(40, null) + const Component = universal(asyncComponent, { timeout: 10 }) @@ -64,7 +74,7 @@ describe('async lifecycle', () => { it('component unmounted: setState not called', async () => { const asyncComponent = createComponent(10, MyComponent) - const Component = universalComponent(asyncComponent) + const Component = universal(asyncComponent) let instance const component = renderer.create( (instance = i)} />) @@ -81,7 +91,7 @@ describe('async lifecycle', () => { describe('props: all components receive props', () => { it('custom loading component', async () => { const asyncComponent = createComponent(40, MyComponent) - const Component = universalComponent(asyncComponent, { + const Component = universal(asyncComponent, { loading: Loading }) @@ -95,13 +105,12 @@ describe('props: all components receive props', () => { expect(component1.toJSON()).toMatchSnapshot() // loaded const component2 = renderer.create() - - expect(component2.toJSON()).toMatchSnapshot() // reload + expect(component2.toJSON()).toMatchSnapshot() // re-loaded }) it('custom error component', async () => { - const asyncComponent = createComponent(40, null, new Error('test error')) - const Component = universalComponent(asyncComponent, { + const asyncComponent = createComponent(40, null) + const Component = universal(asyncComponent, { error: Err }) @@ -115,13 +124,12 @@ describe('props: all components receive props', () => { expect(component1.toJSON()).toMatchSnapshot() // Error! const component2 = renderer.create() - expect(component2.toJSON()).toMatchSnapshot() // loading again.. }) it(' - also displays loading component', async () => { const asyncComponent = createComponent(40, MyComponent) - const Component = universalComponent(asyncComponent) + const Component = universal(asyncComponent) const component1 = renderer.create() expect(component1.toJSON()).toMatchSnapshot() // initial @@ -132,7 +140,7 @@ describe('props: all components receive props', () => { it(' - also displays error component', async () => { const asyncComponent = createComponent(40, MyComponent) - const Component = universalComponent(asyncComponent, { error: Err }) + const Component = universal(asyncComponent, { error: Err }) const component1 = renderer.create() expect(component1.toJSON()).toMatchSnapshot() // initial @@ -143,7 +151,7 @@ describe('props: all components receive props', () => { it('components passed as elements: loading', async () => { const asyncComponent = createComponent(40, ) - const Component = universalComponent(asyncComponent, { + const Component = universal(asyncComponent, { loading: }) @@ -157,13 +165,12 @@ describe('props: all components receive props', () => { expect(component1.toJSON()).toMatchSnapshot() // loaded const component2 = renderer.create() - expect(component2.toJSON()).toMatchSnapshot() // reload }) it('components passed as elements: error', async () => { - const asyncComponent = createComponent(40, null, new Error('test error')) - const Component = universalComponent(asyncComponent, { + const asyncComponent = createComponent(40, null) + const Component = universal(asyncComponent, { error: }) @@ -177,7 +184,6 @@ describe('props: all components receive props', () => { expect(component1.toJSON()).toMatchSnapshot() // Error! const component2 = renderer.create() - expect(component2.toJSON()).toMatchSnapshot() // loading again... }) @@ -191,7 +197,7 @@ describe('props: all components receive props', () => { const component = await Promise.resolve(

{value}
) return component } - const Component = universalComponent(asyncComponent, { + const Component = universal(asyncComponent, { key: null }) @@ -205,8 +211,8 @@ describe('props: all components receive props', () => { describe('server-side rendering', () => { it('es6: default export automatically resolved', async () => { - const asyncComponent = createComponent(40, null, new Error('test error')) - const Component = universalComponent(asyncComponent, { + const asyncComponent = createComponent(40, null) + const Component = universal(asyncComponent, { path: path.join(__dirname, '../__fixtures__/component') }) @@ -216,8 +222,8 @@ describe('server-side rendering', () => { }) it('es5: module.exports resolved', async () => { - const asyncComponent = createComponent(40, null, new Error('test error')) - const Component = universalComponent(asyncComponent, { + const asyncComponent = createComponent(40, null) + const Component = universal(asyncComponent, { path: path.join(__dirname, '../__fixtures__/component.es5') }) @@ -230,7 +236,7 @@ describe('server-side rendering', () => { describe('other options', () => { it('key (string): resolves export to value of key', async () => { const asyncComponent = createComponent(20, { fooKey: MyComponent }) - const Component = universalComponent(asyncComponent, { + const Component = universal(asyncComponent, { key: 'fooKey' }) @@ -246,7 +252,7 @@ describe('other options', () => { it('key (function): resolves export to function return', async () => { const asyncComponent = createComponent(20, { fooKey: MyComponent }) - const Component = universalComponent(asyncComponent, { + const Component = universal(asyncComponent, { key: module => module.fooKey }) @@ -260,25 +266,41 @@ describe('other options', () => { expect(component.toJSON()).toMatchSnapshot() // success }) - it('onLoad (async): is called and passed entire module', async () => { + it('onLoad (async): is called and passed an es6 module', async () => { const onLoad = jest.fn() const mod = { __esModule: true, default: MyComponent } const asyncComponent = createComponent(40, mod) - const Component = universalComponent(asyncComponent, { + const Component = universal(asyncComponent, { onLoad }) + + const component = renderer.create() + + await waitFor(50) + expect(onLoad).toBeCalledWith(mod) + + expect(component.toJSON()).toMatchSnapshot() // success + }) + + it('onLoad (async): is called and passed entire module', async () => { + const onLoad = jest.fn() + const mod = { __esModule: true, foo: MyComponent } + const asyncComponent = createComponent(40, mod) + const Component = universal(asyncComponent, { onLoad, - key: 'default' + key: 'foo' }) - renderer.create() + const component = renderer.create() await waitFor(50) expect(onLoad).toBeCalledWith(mod) + + expect(component.toJSON()).toMatchSnapshot() // success }) it('onLoad (sync): is called and passed entire module', async () => { const onLoad = jest.fn() const asyncComponent = createComponent(40) - const Component = universalComponent(asyncComponent, { + const Component = universal(asyncComponent, { onLoad, key: 'default', path: path.join(__dirname, '..', '__fixtures__', 'component') @@ -291,7 +313,7 @@ describe('other options', () => { it('minDelay: loads for duration of minDelay even if component ready', async () => { const asyncComponent = createComponent(40, MyComponent) - const Component = universalComponent(asyncComponent, { + const Component = universal(asyncComponent, { minDelay: 60 }) @@ -328,6 +350,27 @@ describe('SSR flushing: flushModuleIds() + flushChunkNames()', () => { expect(chunkNames).toEqual(['component', 'component3']) }) + it('babel (babel-plugin)', async () => { + const App = createBablePluginApp() + + flushModuleIds() // insure sets are empty: + flushChunkNames() + + renderer.create() + let paths = flushModuleIds().map(normalizePath) + let chunkNames = flushChunkNames().map(normalizePath) + + expect(paths).toEqual(['/component', '/component2']) + expect(chunkNames).toEqual(['/component', '/component2']) + + renderer.create() + paths = flushModuleIds().map(normalizePath) + chunkNames = flushChunkNames().map(normalizePath) + + expect(paths).toEqual(['/component', '/component3']) + expect(chunkNames).toEqual(['/component', '/component3']) + }) + it('webpack', async () => { global.__webpack_require__ = path => __webpack_modules__[path] @@ -360,23 +403,248 @@ describe('SSR flushing: flushModuleIds() + flushChunkNames()', () => { delete global.__webpack_require__ delete global.__webpack_modules__ }) + + it('webpack (babel-plugin)', async () => { + global.__webpack_require__ = path => __webpack_modules__[path] + + // modules stored by paths instead of IDs (replicates babel implementation) + global.__webpack_modules__ = { + [createPath('component')]: require(createPath('component')), + [createPath('component2')]: require(createPath('component2')), + [createPath('component3')]: require(createPath('component3')) + } + + const App = createBablePluginApp(true) + + flushModuleIds() // insure sets are empty: + flushChunkNames() + + renderer.create() + let paths = flushModuleIds().map(normalizePath) + let chunkNames = flushChunkNames().map(normalizePath) + + expect(paths).toEqual(['/component', '/component2']) + expect(chunkNames).toEqual(['/component', '/component2']) + + renderer.create() + paths = flushModuleIds().map(normalizePath) + chunkNames = flushChunkNames().map(normalizePath) + + expect(paths).toEqual(['/component', '/component3']) + expect(chunkNames).toEqual(['/component', '/component3']) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + }) + + it('babel: dynamic require', async () => { + const App = createDynamicApp() + + flushModuleIds() // insure sets are empty: + flushChunkNames() + + renderer.create() + let paths = flushModuleIds().map(normalizePath) + let chunkNames = flushChunkNames() + + expect(paths).toEqual(['/component', '/component2']) + expect(chunkNames).toEqual(['component', 'component2']) + + renderer.create() + paths = flushModuleIds().map(normalizePath) + chunkNames = flushChunkNames() + + expect(paths).toEqual(['/component', '/component3']) + expect(chunkNames).toEqual(['component', 'component3']) + }) + + it('webpack: dynamic require', async () => { + global.__webpack_require__ = path => __webpack_modules__[path] + + // modules stored by paths instead of IDs (replicates babel implementation) + global.__webpack_modules__ = { + [createPath('component')]: require(createPath('component')), + [createPath('component2')]: require(createPath('component2')), + [createPath('component3')]: require(createPath('component3')) + } + + const App = createDynamicApp(true) + + flushModuleIds() // insure sets are empty: + flushChunkNames() + + renderer.create() + let paths = flushModuleIds().map(normalizePath) + let chunkNames = flushChunkNames() + + expect(paths).toEqual(['/component', '/component2']) + expect(chunkNames).toEqual(['component', 'component2']) + + renderer.create() + paths = flushModuleIds().map(normalizePath) + chunkNames = flushChunkNames() + + expect(paths).toEqual(['/component', '/component3']) + expect(chunkNames).toEqual(['component', 'component3']) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + }) + + it('babel: dynamic require (babel-plugin)', async () => { + const App = createDynamicBablePluginApp() + + flushModuleIds() // insure sets are empty: + flushChunkNames() + + renderer.create() + let paths = flushModuleIds().map(normalizePath) + let chunkNames = flushChunkNames() + + expect(paths).toEqual(['/component', '/component2']) + expect(chunkNames).toEqual(['component', 'component2']) + + renderer.create() + paths = flushModuleIds().map(normalizePath) + chunkNames = flushChunkNames() + + expect(paths).toEqual(['/component', '/component3']) + expect(chunkNames).toEqual(['component', 'component3']) + }) + + it('webpack: dynamic require (babel-plugin', async () => { + global.__webpack_require__ = path => __webpack_modules__[path] + + // modules stored by paths instead of IDs (replicates babel implementation) + global.__webpack_modules__ = { + [createPath('component')]: require(createPath('component')), + [createPath('component2')]: require(createPath('component2')), + [createPath('component3')]: require(createPath('component3')) + } + + const App = createDynamicBablePluginApp(true) + + flushModuleIds() // insure sets are empty: + flushChunkNames() + + renderer.create() + let paths = flushModuleIds().map(normalizePath) + let chunkNames = flushChunkNames() + + expect(paths).toEqual(['/component', '/component2']) + expect(chunkNames).toEqual(['component', 'component2']) + + renderer.create() + paths = flushModuleIds().map(normalizePath) + chunkNames = flushChunkNames() + + expect(paths).toEqual(['/component', '/component3']) + expect(chunkNames).toEqual(['component', 'component3']) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + }) }) -test('Component.preload: static preload method pre-fetches chunk', async () => { - const asyncComponent = createComponent(40, MyComponent) - const Component = universalComponent(asyncComponent) +describe('advanced', () => { + it('dynamic requires (async)', async () => { + const components = { MyComponent } + const asyncComponent = createDynamicComponent(0, components) + const Component = universal(asyncComponent) + + const component = renderer.create() + await waitFor(5) + + expect(component.toJSON()).toMatchSnapshot() // success + }) + + it('Component.preload: static preload method pre-fetches chunk', async () => { + const components = { MyComponent } + const asyncComponent = createDynamicComponent(40, components) + const Component = universal(asyncComponent) + + Component.preload({ page: 'MyComponent' }) + await waitFor(20) + + const component1 = renderer.create() + + expect(component1.toJSON()).toMatchSnapshot() // still loading... + + // without the preload, it still would be loading + await waitFor(22) + expect(component1.toJSON()).toMatchSnapshot() // success + + const component2 = renderer.create() + expect(component2.toJSON()).toMatchSnapshot() // success + }) + + it('promise passed directly', async () => { + const asyncComponent = createComponent(0, MyComponent, new Error('ah')) + + const options = { + chunkName: ({ page }) => page, + error: ({ message }) =>
{message}
+ } + + const Component = universal(asyncComponent(), options) + + const component = renderer.create() + expect(component.toJSON()).toMatchSnapshot() // loading... + + await waitFor(2) + expect(component.toJSON()).toMatchSnapshot() // loaded + }) + + it('babel-plugin', async () => { + const asyncComponent = createBablePluginComponent( + 0, + MyComponent, + new Error('ah'), + 'MyComponent' + ) + const options = { + testBabelPlugin: true, + chunkName: ({ page }) => page + } + + const Component = universal(asyncComponent, options) + + const component = renderer.create() + expect(component.toJSON()).toMatchSnapshot() // loading... - Component.preload() - await waitFor(20) + await waitFor(2) + expect(component.toJSON()).toMatchSnapshot() // loaded + }) - const component1 = renderer.create() + it('componentWillReceiveProps: changes component (dynamic require)', async () => { + const components = { MyComponent, MyComponent2 } + const asyncComponent = createDynamicBablePluginComponent(0, components) + const options = { + testBabelPlugin: true, + chunkName: ({ page }) => page + } - expect(component1.toJSON()).toMatchSnapshot() // still loading... + const Component = universal(asyncComponent, options) - // without the preload, it still would be loading - await waitFor(20) - expect(component1.toJSON()).toMatchSnapshot() // success + class Container extends React.Component { + render() { + const page = (this.state && this.state.page) || 'MyComponent' + return + } + } + + let instance + const component = renderer.create( (instance = i)} />) + expect(component.toJSON()).toMatchSnapshot() // loading... - const component2 = renderer.create() - expect(component2.toJSON()).toMatchSnapshot() // success + await waitFor(2) + expect(component.toJSON()).toMatchSnapshot() // loaded + + instance.setState({ page: 'MyComponent2' }) + + expect(component.toJSON()).toMatchSnapshot() // loading... + await waitFor(2) + + expect(component.toJSON()).toMatchSnapshot() // loaded + }) }) diff --git a/__tests__/requireUniversalModule.js b/__tests__/requireUniversalModule.js new file mode 100644 index 0000000..5cc3df3 --- /dev/null +++ b/__tests__/requireUniversalModule.js @@ -0,0 +1,385 @@ +// @noflow +import path from 'path' +import { createPath, waitFor, normalizePath } from '../__test-helpers__' + +import req, { + flushModuleIds, + flushChunkNames +} from '../src/requireUniversalModule' + +const requireModule = (asyncImport, options, props) => + req(asyncImport, { ...options, modCache: {}, promCache: {} }, props) + +describe('requireSync: tries to require module synchronously on both the server and client', () => { + it('babel', () => { + const modulePath = createPath('es6') + const { requireSync } = requireModule(undefined, { path: modulePath }) + const mod = requireSync() + + const defaultExport = require(modulePath).default + expect(mod).toEqual(defaultExport) + }) + + it('babel: path option as function', () => { + const modulePath = createPath('es6') + const { requireSync } = requireModule(undefined, { path: () => modulePath }) + const mod = requireSync() + + const defaultExport = require(modulePath).default + expect(mod).toEqual(defaultExport) + }) + + it('webpack', () => { + global.__webpack_require__ = path => __webpack_modules__[path] + const modulePath = createPath('es6') + + global.__webpack_modules__ = { + [modulePath]: require(modulePath) + } + + const options = { resolve: () => modulePath } + const { requireSync } = requireModule(undefined, options) + const mod = requireSync() + + const defaultExport = require(modulePath).default + expect(mod).toEqual(defaultExport) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + }) + + it('webpack: resolve option as string', () => { + global.__webpack_require__ = path => __webpack_modules__[path] + const modulePath = createPath('es6.js') + + global.__webpack_modules__ = { + [modulePath]: require(modulePath) + } + + const { requireSync } = requireModule(undefined, { resolve: modulePath }) + const mod = requireSync() + + const defaultExport = require(modulePath).default + expect(mod).toEqual(defaultExport) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + }) + + it('webpack: when mod is undefined, requireSync used instead after all chunks evaluated at render time', () => { + global.__webpack_require__ = path => __webpack_modules__[path] + const modulePath = createPath('es6') + + // main.js chunk is evaluated, but 0.js comes after + global.__webpack_modules__ = {} + + const { requireSync } = requireModule(undefined, { + resolve: () => modulePath + }) + const mod = requireSync() + + expect(mod).toEqual(undefined) + + // 0.js chunk is evaluated, and now the module exists + global.__webpack_modules__ = { + [modulePath]: require(modulePath) + } + + // requireSync is used, for example, at render time after all chunks are evaluated + const modAttempt2 = requireSync() + const defaultExport = require(modulePath).default + expect(modAttempt2).toEqual(defaultExport) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + }) + + it('es5 resolution', () => { + const { requireSync } = requireModule(undefined, { + path: path.join(__dirname, '../__fixtures__/es5') + }) + const mod = requireSync() + + const defaultExport = require('../__fixtures__/es5') + expect(mod).toEqual(defaultExport) + }) + + it('babel: dynamic require', () => { + const modulePath = ({ page }) => createPath(page) + const props = { page: 'es6' } + const options = { path: modulePath } + const { requireSync } = requireModule(null, options, props) + const mod = requireSync(props) + + const defaultExport = require(createPath('es6')).default + expect(mod).toEqual(defaultExport) + }) + + it('webpack: dynamic require', () => { + global.__webpack_require__ = path => __webpack_modules__[path] + const modulePath = ({ page }) => createPath(page) + + global.__webpack_modules__ = { + [createPath('es6')]: require(createPath('es6')) + } + + const props = { page: 'es6' } + const options = { resolve: modulePath } + const { requireSync } = requireModule(undefined, options, props) + const mod = requireSync(props) + + const defaultExport = require(createPath('es6')).default + expect(mod).toEqual(defaultExport) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + }) +}) + +describe('requireAsync: requires module asynchronously on the client, returning a promise', () => { + it('asyncImport as function: () => import()', async () => { + const { requireAsync } = requireModule(() => Promise.resolve('hurray')) + + const res = await requireAsync() + expect(res).toEqual('hurray') + }) + + it('asyncImport as promise: import()', async () => { + const { requireAsync } = requireModule(Promise.resolve('hurray')) + + const res = await requireAsync() + expect(res).toEqual('hurray') + }) + + it('asyncImport as function using callback for require.ensure: (props, { resolve }) => resolve(module)', async () => { + const { requireAsync } = requireModule((props, { resolve }) => + resolve('hurray') + ) + + const res = await requireAsync() + expect(res).toEqual('hurray') + }) + + it('asyncImport as function using callback for require.ensure: (props, { reject }) => reject(error)', async () => { + const { requireAsync } = requireModule((props, { reject }) => + reject(new Error('ah')) + ) + + try { + await requireAsync() + } + catch (error) { + expect(error.message).toEqual('ah') + } + }) + + it('asyncImport as function with props: props => import()', async () => { + const { requireAsync } = requireModule(props => Promise.resolve(props.foo)) + const res = await requireAsync({ foo: 123 }) + expect(res).toEqual(123) + }) + + it('asyncImport as function with props: (props, { resolve }) => cb()', async () => { + const asyncImport = (props, { resolve }) => resolve(props.foo) + const { requireAsync } = requireModule(asyncImport) + const res = await requireAsync({ foo: 123 }) + expect(res).toEqual(123) + }) + + it('return Promise.resolve(mod) if module already synchronously required', async () => { + const modulePath = createPath('es6') + const options = { path: modulePath } + const { requireSync, requireAsync } = requireModule(undefined, options) + const mod = requireSync() + + expect(mod).toBeDefined() + + const prom = requireAsync() + expect(prom.then).toBeDefined() + + const modAgain = await requireAsync() + expect(modAgain).toEqual('hello') + }) + + it('export not found rejects', async () => { + const { requireAsync } = requireModule(() => Promise.resolve('hurray'), { + key: 'dog' + }) + + try { + await requireAsync() + } + catch (error) { + expect(error.message).toEqual('export not found') + } + }) + + it('rejected promise', async () => { + const { requireAsync } = requireModule(Promise.reject(new Error('ah'))) + + try { + await requireAsync() + } + catch (error) { + expect(error.message).toEqual('ah') + } + }) + + it('rejected promise calls onError', async () => { + const error = new Error('ah') + const onError = jest.fn() + const opts = { onError } + const { requireAsync } = requireModule(Promise.reject(error), opts) + + try { + await requireAsync() + } + catch (error) { + expect(error.message).toEqual('ah') + } + + expect(onError).toBeCalledWith(error) + }) +}) + +describe('addModule: add moduleId and chunkName for SSR flushing', () => { + it('babel', () => { + flushModuleIds() // insure sets are empty: + flushChunkNames() + + const moduleEs6 = createPath('es6') + const moduleEs5 = createPath('es5') + + let universal = requireModule(undefined, { + path: moduleEs6, + chunkName: 'es6' + }) + universal.addModule() + + universal = requireModule(undefined, { path: moduleEs5, chunkName: 'es5' }) + universal.addModule() + + const paths = flushModuleIds().map(normalizePath) + const chunkNames = flushChunkNames() + + expect(paths).toEqual(['/es6', '/es5']) + expect(chunkNames).toEqual(['es6', 'es5']) + }) + + it('webpack', () => { + global.__webpack_require__ = path => __webpack_modules__[path] + + const moduleEs6 = createPath('es6') + const moduleEs5 = createPath('es5') + + // modules stored by paths instead of IDs (replicates babel implementation) + global.__webpack_modules__ = { + [moduleEs6]: require(moduleEs6), + [moduleEs5]: require(moduleEs5) + } + + flushModuleIds() // insure sets are empty: + flushChunkNames() + + let universal = requireModule(undefined, { + resolve: () => moduleEs6, + chunkName: 'es6' + }) + universal.addModule() + + universal = requireModule(undefined, { + resolve: () => moduleEs5, + chunkName: 'es5' + }) + universal.addModule() + + const paths = flushModuleIds().map(normalizePath) + const chunkNames = flushChunkNames() + + expect(paths).toEqual(['/es6', '/es5']) + expect(chunkNames).toEqual(['es6', 'es5']) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + }) +}) + +describe('other options', () => { + it('key (string): resolve export to value of key', () => { + const modulePath = createPath('es6') + const { requireSync } = requireModule(undefined, { + path: modulePath, + key: 'foo' + }) + const mod = requireSync() + + const defaultExport = require(modulePath).foo + expect(mod).toEqual(defaultExport) + }) + + it('key (function): resolves export to function return', () => { + const modulePath = createPath('es6') + const { requireSync } = requireModule(undefined, { + path: modulePath, + key: module => module.foo + }) + const mod = requireSync() + + const defaultExport = require(modulePath).foo + expect(mod).toEqual(defaultExport) + }) + + it('key (null): resolves export to be entire module', () => { + const modulePath = createPath('es6') + const { requireSync } = requireModule(undefined, { + path: path.join(__dirname, '../__fixtures__/es6'), + key: null + }) + const mod = requireSync() + + const defaultExport = require('../__fixtures__/es6') + expect(mod).toEqual(defaultExport) + }) + + it('timeout: throws if loading time is longer than timeout', async () => { + const asyncImport = waitFor(20).then('hurray') + const { requireAsync } = requireModule(asyncImport, { timeout: 10 }) + + try { + await requireAsync() + } + catch (error) { + expect(error.message).toEqual('timeout exceeded') + } + }) + + it('onLoad (async): is called and passed entire module', async () => { + const onLoad = jest.fn() + const mod = { __esModule: true, default: 'foo' } + const asyncImport = Promise.resolve(mod) + const { requireAsync } = requireModule(() => asyncImport, { + onLoad, + key: 'default' + }) + + await requireAsync() + + expect(onLoad).toBeCalledWith(mod) + expect(onLoad).not.toBeCalledWith('foo') + }) + + it('onLoad (sync): is called and passed entire module', async () => { + const onLoad = jest.fn() + const mod = { __esModule: true, default: 'foo' } + const asyncImport = Promise.resolve(mod) + const { requireAsync } = requireModule(() => asyncImport, { + onLoad, + key: 'default' + }) + + await requireAsync() + + expect(onLoad).toBeCalledWith(mod) + expect(onLoad).not.toBeCalledWith('foo') + }) +}) diff --git a/__tests__/utils.js b/__tests__/utils.js new file mode 100644 index 0000000..c86c6c5 --- /dev/null +++ b/__tests__/utils.js @@ -0,0 +1,86 @@ +import { createPath } from '../__test-helpers__' + +import { + tryRequire, + requireById, + resolveExport, + findExport +} from '../src/utils' + +test('tryRequire: requires module using key export finder + calls onLoad with module', () => { + const moduleEs6 = createPath('es6') + const expectedModule = require(moduleEs6) + + // babel + let mod = tryRequire(moduleEs6) + expect(mod).toEqual(expectedModule) + + // webpack + global.__webpack_require__ = path => __webpack_modules__[path] + global.__webpack_modules__ = { + [moduleEs6]: expectedModule + } + + mod = tryRequire(moduleEs6) + expect(mod).toEqual(expectedModule) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + + // module not found + mod = tryRequire('/foo') + expect(mod).toEqual(null) +}) + +test('requireById: requires module for babel or webpack depending on environment', () => { + const moduleEs6 = createPath('es6') + const expectedModule = require(moduleEs6) + + // babel + let mod = requireById(moduleEs6) + expect(mod).toEqual(expectedModule) + + // webpack + global.__webpack_require__ = path => __webpack_modules__[path] + global.__webpack_modules__ = { + [moduleEs6]: expectedModule + } + + mod = requireById(moduleEs6) + expect(mod).toEqual(expectedModule) + + delete global.__webpack_require__ + delete global.__webpack_modules__ + + // module not found + expect(() => requireById('/foo')).toThrow() +}) + +test('resolveExport: finds export and calls onLoad', () => { + const onLoad = jest.fn() + const mod = { foo: 'bar' } + const exp = resolveExport(mod, 'foo', onLoad) + expect(exp).toEqual('bar') + expect(onLoad).toBeCalledWith(mod) + // todo: test caching +}) + +test('findExport: finds export in module via key string, function or returns module if key === null', () => { + const mod = { foo: 'bar' } + + // key as string + let exp = findExport(mod, 'foo') + expect(exp).toEqual('bar') + + // key as function + exp = findExport(mod, mod => mod.foo) + expect(exp).toEqual('bar') + + // key as null + exp = findExport(mod, null) + expect(exp).toEqual(mod) + + // default: no key + exp = findExport({ __esModule: true, default: 'baz' }) + expect(exp).toEqual('baz') +}) diff --git a/package.json b/package.json index 330a17a..384c15d 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "babel-cli": "^6.23.0", "babel-core": "^6.24.0", "babel-eslint": "^7.2.3", - "babel-loader": "^7.0.0", "babel-plugin-transform-flow-strip-types": "^6.22.0", + "babel-plugin-universal-import": "^1.0.5", "babel-preset-es2015": "^6.24.1", "babel-preset-flow": "^6.23.0", "babel-preset-react": "^6.24.1", @@ -41,14 +41,14 @@ "eslint-plugin-import": "^2.2.0", "eslint-plugin-jsx-a11y": "^5.0.3", "eslint-plugin-react": "^7.0.1", - "flow-bin": "^0.47.0", + "flow-bin": "^0.49.1", "flow-copy-source": "^1.1.0", "husky": "^0.13.2", "jest": "^20.0.4", "lint-staged": "^3.4.0", "prettier": "^1.3.1", "react": "^15.5.4", - "react-test-renderer": "^15.5.4", + "react-test-renderer": "^15.6.1", "rimraf": "^2.5.4", "semantic-release": "^6.3.6", "travis-github-status": "^1.4.0" diff --git a/server.js b/server.js index 7916608..dc01dde 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,4 @@ module.exports = { - flushModuleIds: require('require-universal-module/server').flushModuleIds, - flushChunkNames: require('require-universal-module/server').flushChunkNames + flushModuleIds: require('./dist/requireUniversalModule').flushModuleIds, + flushChunkNames: require('./dist/requireUniversalModule').flushChunkNames } diff --git a/src/flowTypes.js b/src/flowTypes.js new file mode 100644 index 0000000..d01e0ec --- /dev/null +++ b/src/flowTypes.js @@ -0,0 +1,113 @@ +// @flow +import React from 'react' + +// config object transformed from import() (babel-plugin-universal-import) +export type StrFun = string | ((props?: Object) => string) +export type Config = { + chunkName: StrFun, + path: StrFun, + resolve: StrFun, + load: Load, + id: string, + file: string +} + +export type Load = ( + Object, + AsyncFuncTools +) => Promise<[ImportModule]> | Promise + +// function that returns config (babel-plugin-universal-import) +// $FlowIssue +export type ConfigFunc = (props: Object) => Config + +// promise containing component or function returning it +export type AsyncComponent = + | ((props: Object, AsyncFuncTools) => Promise>) + | Promise> + +// OPTIONS FOR BOTH RUM + RUC + +export type ModuleOptions = { + resolve?: StrFun, // only optional when async-only + chunkName?: string, + path?: StrFun, + key?: Key, + timeout?: number, + onError?: OnError, + onLoad?: OnLoad, + alwaysUpdate?: boolean, + isDynamic: boolean, + modCache: Object, + promCache: Object, + id?: string +} + +export type ComponentOptions = { + loading?: LoadingCompponent, + error?: ErrorComponent, + minDelay?: number, + testBabelPlugin?: boolean, + + // options for requireAsyncModule: + resolve?: StrFun, + path?: StrFun, + chunkName?: string, + timeout?: number, + key?: Key, + onLoad?: OnLoad, + onError?: OnError, + alwaysUpdate?: boolean, + id?: string +} + +// RUM + +export type AsyncFuncTools = { resolve: ResolveImport, reject: RejectImport } +export type ResolveImport = (module: ?any) => void +export type RejectImport = (error: Object) => void +export type Id = string +export type Key = string | null | ((module: ?(Object | Function)) => any) +export type OnLoad = (module: ?(Object | Function)) => void +export type OnError = (error: Object) => void + +export type RequireAsync = (props: Object) => Promise +export type RequireSync = (props: Object) => ?any +export type AddModule = (props: Object) => void +export type Mod = Object | Function +export type Tools = { + requireAsync: RequireAsync, + requireSync: RequireSync, + addModule: AddModule, + shouldUpdate: () => boolean +} + +export type Ids = Array + +// RUC + +export type Props = { + error?: ?any, + isLoading?: ?boolean +} + +export type GenericComponent = + | Class> + | React$Element + +export type Component = GenericComponent +export type LoadingCompponent = GenericComponent<{}> +export type ErrorComponent = GenericComponent<{}> + +// babel-plugin-universal-import +export type ImportModule = + | { + default?: Object | Function + } + | Object + | Function + | ImportError + +export type ImportError = { + message: string +} diff --git a/src/index.js b/src/index.js index 61e5c23..e1af45c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,83 +1,56 @@ // @flow import React from 'react' -import req from 'require-universal-module' - -type Id = string - -type GenericComponent = - | Class> - | React$Element - -type Component = GenericComponent -type LoadingCompponent = GenericComponent<{}> -type ErrorComponent = GenericComponent<{}> - -type AsyncComponent = - | Promise> - | (() => Promise>) -type Key = string | null | ((module: ?Object) => Component) -type OnLoad = (module: Object) => void -type OnError = (error: Object) => void -type PathResolve = Id | (() => Id) -type Options = { - loading?: LoadingCompponent, - error?: ErrorComponent, - minDelay?: number, - - // options for requireAsyncModule: - resolve?: PathResolve, - path?: PathResolve, - chunkName?: string, - timeout?: number, - key?: Key, - onLoad?: OnLoad, - onError?: OnError -} +import req from './requireUniversalModule' -type Props = { - error?: ?any, - isLoading?: ?boolean -} +import type { + Config, + ConfigFunc, + ComponentOptions, + RequireAsync, + Props +} from './flowTypes' + +import { DefaultLoading, DefaultError, isServer, createElement } from './utils' -const DefaultLoading = () =>
Loading...
-const DefaultError = () =>
Error!
+let hasBabelPlugin = false -const isServer = typeof window === 'undefined' -const DEV = process.env.NODE_ENV === 'development' +export const setHasBabelPlugin = () => { + hasBabelPlugin = true +} export default function universal( - component: AsyncComponent, - opts: Options = {} + component: Config | ConfigFunc, + opts: ComponentOptions = {} ) { const { loading: Loading = DefaultLoading, error: Err = DefaultError, minDelay = 0, + testBabelPlugin = false, ...options } = opts - const { requireSync, requireAsync, addModule, mod } = req(component, options) + const isDynamic = hasBabelPlugin || testBabelPlugin + options.isDynamic = isDynamic + options.modCache = {} + options.promCache = {} - let Component = mod // initial syncronous require attempt done for us :) + let Component return class UniversalComponent extends React.Component { _mounted: boolean - static preload(props?: Props) { - return requireAsync(props).catch(e => { - if (DEV) console.warn('[react-universal-component] preload failed:', e) - }) + static preload(props: Props) { + props = props || {} + const { requireAsync } = req(component, options, props) + return requireAsync(props) } constructor(props: Props) { super(props) - if (!Component) { - // try one more syncronous require, in case chunk comes after main.js - // HMR won't work if you've setup your app this way. `mod` must be - // assigned to `Component` in the closure for HMR to work. - Component = requireSync() - } + const { requireSync } = req(component, options, props) + Component = requireSync(props) this.state = { error: null, @@ -85,16 +58,47 @@ export default function universal( } } + componentWillReceiveProps(nextProps: Props) { + if (isDynamic) { + const { requireSync, requireAsync, shouldUpdate } = req( + component, + options, + nextProps, + this.props + ) + + if (shouldUpdate()) { + Component = requireSync(nextProps) + // if !Component, a re-render will happen and show + + if (!Component) { + return this.requireAsync(requireAsync, nextProps) + } + + this.update({ hasComponent: !!Component }) + } + } + } + componentWillMount() { this._mounted = true - addModule() // record the module for SSR flushing :) + const { addModule, requireAsync } = req(component, options, this.props) + addModule(this.props) // record the module for SSR flushing :) if (this.state.hasComponent || isServer) return + this.requireAsync(requireAsync, this.props) + } + + componentWillUnmount() { + this._mounted = false + } + + requireAsync(requireAsync: RequireAsync, props: Props) { const time = new Date() - requireAsync(this.props) - .then((mod: ?any) => { - Component = mod // for HMR updates component must be in closure + requireAsync(props) + .then((exp: ?any) => { + Component = exp // for HMR updates component must be in closure const state = { hasComponent: !!Component } const timeLapsed = new Date() - time @@ -107,12 +111,9 @@ export default function universal( .catch(error => this.update({ error })) } - componentWillUnmount() { - this._mounted = false - } - update = (state: { error?: any, hasComponent?: boolean }) => { if (!this._mounted) return + if (!state.error) state.error = null this.setState(state) } @@ -139,8 +140,3 @@ export default function universal( } } } - -const createElement = (Component: any, props: Props) => - React.isValidElement(Component) - ? React.cloneElement(Component, props) - : diff --git a/src/requireUniversalModule.js b/src/requireUniversalModule.js new file mode 100644 index 0000000..e470d15 --- /dev/null +++ b/src/requireUniversalModule.js @@ -0,0 +1,205 @@ +// @flow +import type { + Tools, + ModuleOptions, + Ids, + Config, + ConfigFunc, + Props, + Load +} from './flowTypes' + +import { + isWebpack, + tryRequire, + resolveExport, + callForString, + loadFromCache, + loadFromPromiseCache, + cacheProm +} from './utils' + +export const IS_TEST = process.env.NODE_ENV === 'test' +export const isServer = typeof window === 'undefined' || IS_TEST + +declare var __webpack_require__: Function +declare var __webpack_modules__: Object + +const CHUNK_NAMES = new Set() +const MODULE_IDS = new Set() + +export default function requireUniversalModule( + universalConfig: Config | ConfigFunc, + options: ModuleOptions, + props: Props, + prevProps?: Props +): Tools { + const { + key, + timeout = 15000, + onLoad, + onError, + isDynamic, + modCache, + promCache + } = options + + const config = getConfig(isDynamic, universalConfig, options, props) + const { chunkName, path, resolve, load } = config + + const requireSync = (props: Object): ?any => { + let exp = loadFromCache(chunkName, props, modCache) + + if (!exp) { + let mod + + if (!isWebpack() && path) { + const modulePath = callForString(path, props) || '' + mod = tryRequire(modulePath) + } + else if (isWebpack() && resolve) { + const weakId = callForString(resolve, props) + + if (__webpack_modules__[weakId]) { + mod = tryRequire(weakId) + } + } + + if (mod) { + exp = resolveExport(mod, key, onLoad, chunkName, props, modCache) + } + } + + return exp + } + + const requireAsync = (props: Object): Promise => { + const exp = loadFromCache(chunkName, props, modCache) + if (exp) return Promise.resolve(exp) + + const cachedPromise = loadFromPromiseCache(chunkName, props, promCache) + if (cachedPromise) return cachedPromise + + const prom = new Promise((res, rej) => { + const timer = + timeout && + setTimeout(() => { + rej(new Error('timeout exceeded')) + }, timeout) + + const resolve = mod => { + clearTimeout(timer) + + const exp = resolveExport(mod, key, onLoad, chunkName, props, modCache) + if (exp) return res(exp) + + rej(new Error('export not found')) + } + + const reject = error => { + clearTimeout(timer) + rej(error) + } + + const request = load(props, { resolve, reject }) + + // if load doesn't return a promise, it must call resolveImport + // itself. Most common is the promise implementation below. + if (!request || typeof request.then !== 'function') return + + request + .then(mod => { + // babel-plugin-universal-import transforms promise to: Promise.all([import(), importcss()]) + if (Array.isArray(mod)) mod = mod[0] + return resolve(mod) + }) + .catch(error => { + clearTimeout(timer) + if (onError) onError(error) + reject(error) + }) + }) + + cacheProm(prom, chunkName, props, promCache) + return prom + } + + const addModule = (props: Object): void => { + if (isServer) { + if (isWebpack()) { + const weakId = callForString(resolve, props) + if (weakId) MODULE_IDS.add(weakId) + } + else if (!isWebpack()) { + const modulePath = callForString(path, props) + if (modulePath) MODULE_IDS.add(modulePath) + } + + // just fill both sets so `flushModuleIds` continues to work, + // even if you decided to start providing chunk names. It's + // a small array of 3-20 chunk names on average anyway. Users + // can flush/clear both sets if they feel they need to. + if (chunkName) { + const name = callForString(chunkName, props) + if (name) CHUNK_NAMES.add(name) + } + } + } + + const shouldUpdate = (): boolean => { + if (!prevProps) return false + + const cacheKey = callForString(chunkName, props) + + const config = getConfig(isDynamic, universalConfig, options, prevProps) + const prevCacheKey = callForString(config.chunkName, prevProps) + + return cacheKey !== prevCacheKey + } + + return { + requireSync, + requireAsync, + addModule, + shouldUpdate + } +} + +export const flushChunkNames = (): Ids => { + const chunks = Array.from(CHUNK_NAMES) + CHUNK_NAMES.clear() + return chunks +} + +export const flushModuleIds = (): Ids => { + const ids = Array.from(MODULE_IDS) + MODULE_IDS.clear() + return ids +} + +const getConfig = ( + isDynamic: ?boolean, + universalConfig: Config | ConfigFunc, + options: ModuleOptions, + props: Props +): Config => { + if (isDynamic) { + return typeof universalConfig === 'function' + ? universalConfig(props) + : universalConfig + } + + const load: Load = typeof universalConfig === 'function' + ? universalConfig + : // $FlowIssue + () => universalConfig + + return { + file: 'default', + id: options.id || 'default', + chunkName: options.chunkName || 'default', + resolve: options.resolve || '', + path: options.path || '', + load + } +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..ae86103 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,88 @@ +// @flow +import React from 'react' + +import type { Id, Key, OnLoad, Mod, StrFun, ImportModule } from './flowTypes' + +export const isServer = typeof window === 'undefined' +export const isWebpack = () => typeof __webpack_require__ !== 'undefined' +export const babelInterop = (mod: ?Mod) => + mod && typeof mod === 'object' && mod.__esModule ? mod.default : mod + +export const DefaultLoading = () =>
Loading...
+export const DefaultError = () =>
Error!
+ +export const tryRequire = (id: Id): ?any => { + try { + return requireById(id) + } + catch (err) {} + + return null +} + +export const requireById = (id: Id): ?any => { + if (!isWebpack() && typeof id === 'string') { + return module.require(id) + } + + return __webpack_require__(id) +} + +export const resolveExport = ( + mod: ?Mod, + key?: Key, + onLoad?: OnLoad, + chunkName?: StrFun, + props: Object, + modCache: Object +) => { + const exp = findExport(mod, key) + if (onLoad && mod) onLoad(mod) + if (chunkName && exp) cacheExport(exp, chunkName, props, modCache) + return exp +} + +export const findExport = (mod: ?Mod, key?: Key): ?any => { + if (typeof key === 'function') { + return key(mod) + } + else if (key === null) { + return mod + } + + return mod && typeof mod === 'object' && key ? mod[key] : babelInterop(mod) +} + +export const createElement = (Component: any, props: {}) => + React.isValidElement(Component) + ? React.cloneElement(Component, props) + : + +export const callForString = (strFun: StrFun, props: Object) => + typeof strFun === 'function' ? strFun(props) : strFun + +export const loadFromCache = ( + chunkName: StrFun, + props: Object, + modCache: Object +) => modCache[callForString(chunkName, props)] + +export const cacheExport = ( + exp: any, + chunkName: StrFun, + props: Object, + modCache: Object +) => (modCache[callForString(chunkName, props)] = exp) + +export const loadFromPromiseCache = ( + chunkName: StrFun, + props: Object, + promisecache: Object +) => promisecache[callForString(chunkName, props)] + +export const cacheProm = ( + pr: Promise<*>, + chunkName: StrFun, + props: Object, + promisecache: Object +) => (promisecache[callForString(chunkName, props)] = pr) diff --git a/wallaby.js b/wallaby.js index 932842b..de66596 100644 --- a/wallaby.js +++ b/wallaby.js @@ -13,7 +13,8 @@ module.exports = wallaby => { filesWithNoCoverageCalculated: [ '__fixtures__/**/*.js', - '__test-helpers__/**/*.js' + '__test-helpers__/**/*.js', + 'server.js' ], tests: ['__tests__/**/*.js'], diff --git a/yarn.lock b/yarn.lock index 64d8026..1f33c53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -482,14 +482,6 @@ babel-jest@^20.0.3: babel-plugin-istanbul "^4.0.0" babel-preset-jest "^20.0.3" -babel-loader@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.0.0.tgz#2e43a66bee1fff4470533d0402c8a4532fafbaf7" - dependencies: - find-cache-dir "^0.1.1" - loader-utils "^1.0.2" - mkdirp "^0.5.1" - babel-messages@^6.23.0: version "6.23.0" resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e" @@ -865,6 +857,15 @@ babel-plugin-transform-strict-mode@^6.24.1: babel-runtime "^6.22.0" babel-types "^6.24.1" +babel-plugin-universal-import@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/babel-plugin-universal-import/-/babel-plugin-universal-import-1.0.5.tgz#6c084a94282b1f09d226520559052ea14aab7ef4" + dependencies: + babel-plugin-syntax-dynamic-import "^6.18.0" + babel-preset-es2015 "^6.24.1" + babel-preset-react "^6.24.1" + babel-preset-stage-2 "^6.24.1" + babel-polyfill@^6.16.0, babel-polyfill@^6.23.0: version "6.23.0" resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.23.0.tgz#8364ca62df8eafb830499f699177466c3b03499d" @@ -1075,10 +1076,6 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" -big.js@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.1.3.tgz#4cada2193652eb3ca9ec8e55c9015669c9806978" - binary-extensions@^1.0.0: version "1.8.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.8.0.tgz#48ec8d16df4377eae5fa5884682480af4d95c774" @@ -1362,10 +1359,6 @@ commitizen@^2.9.6: shelljs "0.7.6" strip-json-comments "2.0.1" -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1695,10 +1688,6 @@ emoji-regex@^6.1.0: version "6.4.2" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.4.2.tgz#a30b6fee353d406d96cfb9fa765bdc82897eff6e" -emojis-list@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" - encoding@^0.1.11: version "0.1.12" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" @@ -2118,14 +2107,6 @@ filled-array@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/filled-array/-/filled-array-1.1.0.tgz#c3c4f6c663b923459a9aa29912d2d031f1507f84" -find-cache-dir@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-0.1.1.tgz#c8defae57c8a52a8a784f9e31c57c742e993a0b9" - dependencies: - commondir "^1.0.1" - mkdirp "^0.5.1" - pkg-dir "^1.0.0" - find-node-modules@1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/find-node-modules/-/find-node-modules-1.0.4.tgz#b6deb3cccb699c87037677bcede2c5f5862b2550" @@ -2172,9 +2153,9 @@ flat-cache@^1.2.1: graceful-fs "^4.1.2" write "^0.2.1" -flow-bin@^0.47.0: - version "0.47.0" - resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.47.0.tgz#a2a08ab3e0d1f1cb57d17e27b30b118b62fda367" +flow-bin@^0.49.1: + version "0.49.1" + resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.49.1.tgz#c9e456b3173a7535a4ffaf28956352c63bb8e3e9" flow-copy-source@^1.1.0: version "1.1.0" @@ -3441,14 +3422,6 @@ load-json-file@^2.0.0: pify "^2.0.0" strip-bom "^3.0.0" -loader-utils@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" - dependencies: - big.js "^3.1.3" - emojis-list "^2.0.0" - json5 "^0.5.0" - locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -4279,9 +4252,9 @@ rc@^1.0.1, rc@^1.1.6, rc@~1.1.6: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-test-renderer@^15.5.4: - version "15.5.4" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-15.5.4.tgz#d4ebb23f613d685ea8f5390109c2d20fbf7c83bc" +react-test-renderer@^15.6.1: + version "15.6.1" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-15.6.1.tgz#026f4a5bb5552661fd2cc4bbcd0d4bc8a35ebf7e" dependencies: fbjs "^0.8.9" object-assign "^4.1.0"