From e3c737d5c97445782bfd768092d494165ff33b10 Mon Sep 17 00:00:00 2001 From: Victor Date: Mon, 4 Feb 2019 10:29:22 -0800 Subject: [PATCH] feat(babel-plugin): transform code annotated with magic comment Transform imports inside code annotated with `/* #__LOADABLE__ * /`. Fixes #192 --- .../src/__snapshots__/index.test.js.snap | 164 ++++++++++++++++++ packages/babel-plugin/src/index.js | 88 +++++++--- packages/babel-plugin/src/index.test.js | 40 +++++ .../src/properties/requireAsync.js | 11 +- website/src/pages/docs/babel-plugin.mdx | 117 +++++++++++++ 5 files changed, 396 insertions(+), 24 deletions(-) create mode 100644 website/src/pages/docs/babel-plugin.mdx diff --git a/packages/babel-plugin/src/__snapshots__/index.test.js.snap b/packages/babel-plugin/src/__snapshots__/index.test.js.snap index 0d2d08eb..623c2ba4 100644 --- a/packages/babel-plugin/src/__snapshots__/index.test.js.snap +++ b/packages/babel-plugin/src/__snapshots__/index.test.js.snap @@ -1,5 +1,169 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`plugin Magic comment should remove only needed comments 1`] = ` +"const load = +/* IMPORTANT! */ +{ + chunkName() { + return \\"moment\\"; + }, + + isReady(props) { + if (typeof __webpack_modules__ !== 'undefined') { + return !!__webpack_modules__[this.resolve(props)]; + } + + return false; + }, + + requireAsync: () => import( + /* webpackChunkName: \\"moment\\" */ + 'moment'), + + requireSync(props) { + const id = this.resolve(props); + + if (typeof __webpack_require__ !== 'undefined') { + return __webpack_require__(id); + } + + return eval('module.require')(id); + }, + + resolve() { + if (require.resolveWeak) { + return require.resolveWeak(\\"moment\\"); + } + + return require('path').resolve(__dirname, \\"moment\\"); + } + +};" +`; + +exports[`plugin Magic comment should transpile arrow functions 1`] = ` +"const load = { + chunkName() { + return \\"moment\\"; + }, + + isReady(props) { + if (typeof __webpack_modules__ !== 'undefined') { + return !!__webpack_modules__[this.resolve(props)]; + } + + return false; + }, + + requireAsync: () => import( + /* webpackChunkName: \\"moment\\" */ + 'moment'), + + requireSync(props) { + const id = this.resolve(props); + + if (typeof __webpack_require__ !== 'undefined') { + return __webpack_require__(id); + } + + return eval('module.require')(id); + }, + + resolve() { + if (require.resolveWeak) { + return require.resolveWeak(\\"moment\\"); + } + + return require('path').resolve(__dirname, \\"moment\\"); + } + +};" +`; + +exports[`plugin Magic comment should transpile function expression 1`] = ` +"const load = { + chunkName() { + return \\"moment\\"; + }, + + isReady(props) { + if (typeof __webpack_modules__ !== 'undefined') { + return !!__webpack_modules__[this.resolve(props)]; + } + + return false; + }, + + requireAsync: function () { + return import( + /* webpackChunkName: \\"moment\\" */ + 'moment'); + }, + + requireSync(props) { + const id = this.resolve(props); + + if (typeof __webpack_require__ !== 'undefined') { + return __webpack_require__(id); + } + + return eval('module.require')(id); + }, + + resolve() { + if (require.resolveWeak) { + return require.resolveWeak(\\"moment\\"); + } + + return require('path').resolve(__dirname, \\"moment\\"); + } + +};" +`; + +exports[`plugin Magic comment should transpile shortand properties 1`] = ` +"const obj = { + load: { + chunkName() { + return \\"moment\\"; + }, + + isReady(props) { + if (typeof __webpack_modules__ !== 'undefined') { + return !!__webpack_modules__[this.resolve(props)]; + } + + return false; + }, + + requireAsync: () => { + return import( + /* webpackChunkName: \\"moment\\" */ + 'moment'); + }, + + requireSync(props) { + const id = this.resolve(props); + + if (typeof __webpack_require__ !== 'undefined') { + return __webpack_require__(id); + } + + return eval('module.require')(id); + }, + + resolve() { + if (require.resolveWeak) { + return require.resolveWeak(\\"moment\\"); + } + + return require('path').resolve(__dirname, \\"moment\\"); + } + + } +};" +`; + exports[`plugin aggressive import should work with destructuration 1`] = ` "loadable({ chunkName({ diff --git a/packages/babel-plugin/src/index.js b/packages/babel-plugin/src/index.js index decfa13e..ba8a7652 100644 --- a/packages/babel-plugin/src/index.js +++ b/packages/babel-plugin/src/index.js @@ -13,6 +13,8 @@ const properties = [ resolveProperty, ] +const LOADABLE_COMMENT = '#__LOADABLE__' + const loadablePlugin = api => { const { types: t } = api @@ -42,34 +44,74 @@ const loadablePlugin = api => { ) } - return { - inherits: syntaxDynamicImport, - visitor: { - CallExpression(path) { - if (!isValidIdentifier(path)) return + function hasLoadableComment(path) { + const comments = path.get('leadingComments') + const comment = comments.find( + ({ node }) => + node && node.value && String(node.value).includes(LOADABLE_COMMENT), + ) + if (!comment) return false + comment.remove() + return true + } + + function getFuncPath(path) { + const funcPath = path.isCallExpression() ? path.get('arguments.0') : path + if ( + !funcPath.isFunctionExpression() && + !funcPath.isArrowFunctionExpression() && + !funcPath.isObjectMethod() + ) { + return null + } + return funcPath + } + + function transformImport(path) { + const callPaths = collectImportCallPaths(path) - const callPaths = collectImportCallPaths(path) + // Ignore loadable function that does not have any "import" call + if (callPaths.length === 0) return - // Ignore loadable function that does not have any "import" call - if (callPaths.length === 0) return + // Multiple imports call is not supported + if (callPaths.length > 1) { + throw new Error( + 'loadable: multiple import calls inside `loadable()` function are not supported.', + ) + } - // Multiple imports call is not supported - if (callPaths.length > 1) { - throw new Error( - 'loadable: multiple import calls inside `loadable()` function are not supported.', - ) - } + const [callPath] = callPaths - const [callPath] = callPaths - const funcPath = path.get('arguments.0') + const funcPath = getFuncPath(path) + if (!funcPath) return - funcPath.replaceWith( - t.objectExpression( - propertyFactories.map(getProperty => - getProperty({ path, callPath, funcPath }), - ), - ), - ) + funcPath.node.params = funcPath.node.params || [] + + const object = t.objectExpression( + propertyFactories.map(getProperty => + getProperty({ path, callPath, funcPath }), + ), + ) + + if (funcPath.isObjectMethod()) { + funcPath.replaceWith( + t.objectProperty(funcPath.node.key, object, funcPath.node.computed), + ) + } else { + funcPath.replaceWith(object) + } + } + + return { + inherits: syntaxDynamicImport, + visitor: { + CallExpression(path) { + if (!isValidIdentifier(path)) return + transformImport(path) + }, + 'ArrowFunctionExpression|FunctionExpression|ObjectMethod': path => { + if (!hasLoadableComment(path)) return + transformImport(path) }, }, } diff --git a/packages/babel-plugin/src/index.test.js b/packages/babel-plugin/src/index.test.js index e4fcb5cf..942edea4 100644 --- a/packages/babel-plugin/src/index.test.js +++ b/packages/babel-plugin/src/index.test.js @@ -122,4 +122,44 @@ describe('plugin', () => { expect(result).toMatchSnapshot() }) }) + + describe('Magic comment', () => { + it('should transpile shortand properties', () => { + const result = testPlugin(` + const obj = { + /* #__LOADABLE__ */ + load() { + return import('moment') + } + } + `) + + expect(result).toMatchSnapshot() + }) + + it('should transpile arrow functions', () => { + const result = testPlugin(` + const load = /* #__LOADABLE__ */ () => import('moment') + `) + + expect(result).toMatchSnapshot() + }) + + it('should transpile function expression', () => { + const result = testPlugin(` + const load = /* #__LOADABLE__ */ function () { + return import('moment') + } + `) + expect(result).toMatchSnapshot() + }) + + it('should remove only needed comments', () => { + const result = testPlugin(` + const load = /* #__LOADABLE__ */ /* IMPORTANT! */ () => import('moment') + `) + + expect(result).toMatchSnapshot() + }) + }) }) diff --git a/packages/babel-plugin/src/properties/requireAsync.js b/packages/babel-plugin/src/properties/requireAsync.js index f3275fbc..e0b27a79 100644 --- a/packages/babel-plugin/src/properties/requireAsync.js +++ b/packages/babel-plugin/src/properties/requireAsync.js @@ -1,4 +1,13 @@ export default function requireAsyncProperty({ types: t }) { + function getFunc(funcPath) { + if (funcPath.isObjectMethod()) { + const { params, body, async } = funcPath.node + return t.arrowFunctionExpression(params, body, async) + } + + return funcPath.node + } + return ({ funcPath }) => - t.objectProperty(t.identifier('requireAsync'), funcPath.node) + t.objectProperty(t.identifier('requireAsync'), getFunc(funcPath)) } diff --git a/website/src/pages/docs/babel-plugin.mdx b/website/src/pages/docs/babel-plugin.mdx new file mode 100644 index 00000000..a1266c08 --- /dev/null +++ b/website/src/pages/docs/babel-plugin.mdx @@ -0,0 +1,117 @@ +--- +menu: Guides +title: Babel plugin +order: 70 +--- + +# Babel plugin + +This plugin adds support for [Server Side Rendering](/docs/server-side-rendering) and automatic chunk names. + +## Usage + +Install the babel plugin first: + +```bash +npm install --save-dev @loadable/babel-plugin +``` + +Then add it to your babel configuration like so: + +```json +{ + "plugins": ["@loadable/babel-plugin"] +} +``` + +## Transformation + +The plugin transforms your code to be ready for Server Side Rendering, it turns a loadable call: + +```js +import loadable from '@loadable/component' + +const OtherComponent = loadable(() => import('./OtherComponent')) +``` + +into another one with some informations required for Server Side Rendering: + +```js +import loadable from '@loadable/component' +const OtherComponent = loadable({ + chunkName() { + return 'OtherComponent' + }, + + isReady(props) { + if (typeof __webpack_modules__ !== 'undefined') { + return !!__webpack_modules__[this.resolve(props)] + } + + return false + }, + + requireAsync: () => + import(/* webpackChunkName: "OtherComponent" */ + './OtherComponent'), + + requireSync(props) { + const id = this.resolve(props) + + if (typeof __webpack_require__ !== 'undefined') { + return __webpack_require__(id) + } + + return eval('module.require')(id) + }, + + resolve() { + if (require.resolveWeak) { + return require.resolveWeak('./OtherComponent') + } + + return require('path').resolve(__dirname, './OtherComponent') + }, +}) +``` + +As you can see the "webpackChunkName" annotation is automatically added. + +On client side, the two code are completely compatible. + +## Loadable detection + +The detection of a loadable component is based on the keyword "loadable". It is an opiniated choice, it gives you flexibility but it could also be restrictive. + +This code will not be transformed by the babel plugin: + +```js +import load from '@loadable/component' +const OtherComponent = load(() => import('./OtherComponent')) +``` + +The `load` function is not detected, you have to name it `loadable`. + +It is restrictive, yes but it could gives you some flexibility. You can create your own loadable function. In the following example we create a custom loadable function with a custom fallback: + +```js +import baseLoadable from '@loadable/component' + +function loadable(func) { + return baseLoadable(func, { fallback:
Loading...
}) +} + +const OtherComponent = loadable(() => import('./OtherComponent')) +``` + +## Magic comments + +To gives you flexibility and portability, the babel plugin supports magic comment. This way you can create portable "load" functions. To create a "load" function, you have to add `/* #__LOADABLE__ */` comment above the declaration of your function (variable or property): + +```js +const loadOther = /* #__LOADABLE__ */ () => import('./OtherComponent') + +const OtherComponent = loadable(loadOther) +``` + +The `loadOther` function will be transformed into an object, this way you can manipulate function and it is still portable!