From bbf8ab081601bfa812ebd666c3078a3fb331f161 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Tue, 26 Jan 2021 09:39:50 -0500 Subject: [PATCH] Implementing a way to make user-land controllers lazy --- CHANGELOG.md | 6 +- README.md | 31 +++++++ dist/util/get-stimulus-comment-options.js | 86 +++++++++++++++++++ dist/webpack/lazy-controller-loader.js | 66 ++++++++++++++ lazy-controller-loader.js | 12 +++ package.json | 7 +- src/util/get-stimulus-comment-options.js | 64 ++++++++++++++ src/webpack/lazy-controller-loader.js | 52 +++++++++++ .../util/get-stimulus-comment-options.test.js | 45 ++++++++++ test/webpack/lazy-controller-loader.test.js | 50 +++++++++++ 10 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 dist/util/get-stimulus-comment-options.js create mode 100644 dist/webpack/lazy-controller-loader.js create mode 100644 lazy-controller-loader.js create mode 100644 src/util/get-stimulus-comment-options.js create mode 100644 src/webpack/lazy-controller-loader.js create mode 100644 test/util/get-stimulus-comment-options.test.js create mode 100644 test/webpack/lazy-controller-loader.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f71f81..d3003a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,10 @@ in `controllers.json` to `lazy`, your controller will not be downloaded until the controller element first appears on the page. -* The `webpackMode` option in `controllers.json` was deprecated. Use - the new `fetch` option instead. +* Support for making your own controllers "lazy" (as described above) + can now be achieved by loading your controllers through the + `@symfony/stimulus-bridge/lazy-controller-loader` loader and + adding a `/* stimulusFetch: 'lazy' */` comment above your controller. ## 1.1.0 diff --git a/README.md b/README.md index f497d75..e2a0476 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,37 @@ ordered from least to most lazy: separate file and only downloaded asynchronously if (and when) the `data-controller` HTML appears on the page. +## Lazy Controllers + +You can also make your own controllers "lazy": giving them the same behavior +as the `lazy-controller` explained above. In this case, your controller isn't +downloaded until an element for that controller first appears on the page. + +To activate this, first make sure that you're using the special loader - +`@symfony/stimulus-bridge/lazy-controller-loader` - when loading your controllers: + +```js +// assets/bootstrap.js + +export const app = startStimulusApp(require.context( + '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', + true, + /\.(j|t)sx?$/ +)); +``` + +Next, you can make any controllers lazy by adding a `/* stimulusFetch: 'lazy' */` +comment above that controller: + +```js +import { Controller } from 'stimulus'; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + // ... +} +``` + ## Run tests ```sh diff --git a/dist/util/get-stimulus-comment-options.js b/dist/util/get-stimulus-comment-options.js new file mode 100644 index 0000000..c3ed76a --- /dev/null +++ b/dist/util/get-stimulus-comment-options.js @@ -0,0 +1,86 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +'use strict'; + +function _createForOfIteratorHelper(o, allowArrayLike) { var it; if (typeof Symbol === "undefined" || o[Symbol.iterator] == null) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = o[Symbol.iterator](); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; } + +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } + +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } + +var acorn = require('acorn'); + +var vm = require('vm'); + +var stimulusCommentRegExp = new RegExp(/(^|\W)stimulus[A-Z]{1,}[A-Za-z]{1,}:/); +var EMPTY_COMMENT_OPTIONS = { + options: {}, + errors: [] +}; + +function getCommentsFromSource(source) { + var comments = []; + acorn.parse(source, { + onComment: comments, + sourceType: 'module', + ecmaVersion: 2020 + }); + return comments; +} +/** + * Inspired by Webpack's JavaScriptParser + */ + + +module.exports = function parseComments(source) { + var comments; + + try { + comments = getCommentsFromSource(source); + } catch (e) { + return EMPTY_COMMENT_OPTIONS; + } + + if (comments.length === 0) { + return EMPTY_COMMENT_OPTIONS; + } + + var options = {}; + var errors = []; + + var _iterator = _createForOfIteratorHelper(comments), + _step; + + try { + for (_iterator.s(); !(_step = _iterator.n()).done;) { + var comment = _step.value; + var value = comment.value; + + if (value && stimulusCommentRegExp.test(value)) { + // try compile only if stimulus options comment is present + try { + var val = vm.runInNewContext("(function(){return {".concat(value, "};})()")); + Object.assign(options, val); + } catch (e) { + e.comment = comment; + errors.push(e); + } + } + } + } catch (err) { + _iterator.e(err); + } finally { + _iterator.f(); + } + + return { + options: options, + errors: errors + }; +}; \ No newline at end of file diff --git a/dist/webpack/lazy-controller-loader.js b/dist/webpack/lazy-controller-loader.js new file mode 100644 index 0000000..48a55df --- /dev/null +++ b/dist/webpack/lazy-controller-loader.js @@ -0,0 +1,66 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +'use strict'; + +function _createForOfIteratorHelper(o, allowArrayLike) { var it; if (typeof Symbol === "undefined" || o[Symbol.iterator] == null) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = o[Symbol.iterator](); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; } + +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } + +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } + +var generateLazyController = require('./generate-lazy-controller'); + +var getStimulusCommentOptions = require('../util/get-stimulus-comment-options'); +/** + * Loader that can make a Stimulus controller lazy. + * + * This loader is meant to be used to load the Stimulus controllers + * themselves. It detects a stimulusFetch: 'lazy' comment above the + * controller. If present, the controller is replaced by a controller + * that will lazily import the real controller the first time the + * element appears. + * + * @param {string} source of a module that exports a Stimulus controller + * @return {string} + */ + + +module.exports = function (source) { + var _getStimulusCommentOp = getStimulusCommentOptions(source), + options = _getStimulusCommentOp.options, + errors = _getStimulusCommentOp.errors; + + var _iterator = _createForOfIteratorHelper(errors), + _step; + + try { + for (_iterator.s(); !(_step = _iterator.n()).done;) { + var error = _step.value; + this.emitError(new Error("Invalid comment found:\n\n \"/* ".concat(error.comment.value.trim(), " */\".\n\nCheck your syntax."))); + } + } catch (err) { + _iterator.e(err); + } finally { + _iterator.f(); + } + + var stimulusFetch = typeof options.stimulusFetch !== 'undefined' ? options.stimulusFetch : 'eager'; + + if (!['eager', 'lazy'].includes(stimulusFetch)) { + this.emitError(new Error("Invalid value \"".concat(stimulusFetch, "\" found for \"stimulusFetch\". Allowed values are \"lazy\" or \"eager\""))); + } + + var isLazy = stimulusFetch === 'lazy'; + + if (!isLazy) { + return source; + } + + return "import { Controller } from 'stimulus';\nexport default ".concat(generateLazyController(this.resource, 0)); +}; \ No newline at end of file diff --git a/lazy-controller-loader.js b/lazy-controller-loader.js new file mode 100644 index 0000000..d1118f2 --- /dev/null +++ b/lazy-controller-loader.js @@ -0,0 +1,12 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +module.exports = require('./dist/webpack/lazy-controller-loader'); diff --git a/package.json b/package.json index 13da1a9..f5a497b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "peerDependencies": { "stimulus": "^2.0" }, - "dependencies": {}, + "dependencies": { + "acorn": "^8.0.5" + }, "devDependencies": { "@babel/cli": "^7.12.1", "@babel/core": "^7.12.3", @@ -35,6 +37,7 @@ "files": [ "src/", "dist/", - "controllers.json" + "controllers.json", + "lazy-controller-loader.js" ] } diff --git a/src/util/get-stimulus-comment-options.js b/src/util/get-stimulus-comment-options.js new file mode 100644 index 0000000..7f25f89 --- /dev/null +++ b/src/util/get-stimulus-comment-options.js @@ -0,0 +1,64 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +const acorn = require('acorn'); +const vm = require('vm'); + +const stimulusCommentRegExp = new RegExp(/(^|\W)stimulus[A-Z]{1,}[A-Za-z]{1,}:/); + +const EMPTY_COMMENT_OPTIONS = { + options: {}, + errors: [], +}; + +function getCommentsFromSource(source) { + const comments = []; + acorn.parse(source, { + onComment: comments, + sourceType: 'module', + ecmaVersion: 2020, + }); + + return comments; +} + +/** + * Inspired by Webpack's JavaScriptParser + */ +module.exports = function parseComments(source) { + let comments; + try { + comments = getCommentsFromSource(source); + } catch (e) { + return EMPTY_COMMENT_OPTIONS; + } + + if (comments.length === 0) { + return EMPTY_COMMENT_OPTIONS; + } + + let options = {}; + let errors = []; + for (const comment of comments) { + const { value } = comment; + if (value && stimulusCommentRegExp.test(value)) { + // try compile only if stimulus options comment is present + try { + const val = vm.runInNewContext(`(function(){return {${value}};})()`); + Object.assign(options, val); + } catch (e) { + e.comment = comment; + errors.push(e); + } + } + } + return { options, errors }; +}; diff --git a/src/webpack/lazy-controller-loader.js b/src/webpack/lazy-controller-loader.js new file mode 100644 index 0000000..9e4fc6a --- /dev/null +++ b/src/webpack/lazy-controller-loader.js @@ -0,0 +1,52 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +const generateLazyController = require('./generate-lazy-controller'); +const getStimulusCommentOptions = require('../util/get-stimulus-comment-options'); + +/** + * Loader that can make a Stimulus controller lazy. + * + * This loader is meant to be used to load the Stimulus controllers + * themselves. It detects a stimulusFetch: 'lazy' comment above the + * controller. If present, the controller is replaced by a controller + * that will lazily import the real controller the first time the + * element appears. + * + * @param {string} source of a module that exports a Stimulus controller + * @return {string} + */ +module.exports = function (source) { + const { options, errors } = getStimulusCommentOptions(source); + + for (const error of errors) { + this.emitError( + new Error(`Invalid comment found:\n\n "/* ${error.comment.value.trim()} */".\n\nCheck your syntax.`) + ); + } + + const stimulusFetch = typeof options.stimulusFetch !== 'undefined' ? options.stimulusFetch : 'eager'; + if (!['eager', 'lazy'].includes(stimulusFetch)) { + this.emitError( + new Error( + `Invalid value "${stimulusFetch}" found for "stimulusFetch". Allowed values are "lazy" or "eager"` + ) + ); + } + const isLazy = stimulusFetch === 'lazy'; + + if (!isLazy) { + return source; + } + + return `import { Controller } from 'stimulus'; +export default ${generateLazyController(this.resource, 0)}`; +}; diff --git a/test/util/get-stimulus-comment-options.test.js b/test/util/get-stimulus-comment-options.test.js new file mode 100644 index 0000000..9d3ee48 --- /dev/null +++ b/test/util/get-stimulus-comment-options.test.js @@ -0,0 +1,45 @@ +/* + * This file is part of the Symfony Webpack Encore package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +const getStimulusCommentOptions = require('../../dist/util/get-stimulus-comment-options'); + +describe('getStimulusCommentOptions', () => { + it('parses source with no comments', () => { + const src = 'export default class extends Controller {}'; + expect(getStimulusCommentOptions(src)).toEqual({ + options: {}, + errors: [], + }); + }); + + it('parses source with matching and non-matching comments', () => { + const src = '/* stimulusOption: "foo" */ /* somethingElse: "bar" */ export default class extends Controller {}'; + expect(getStimulusCommentOptions(src)).toEqual({ + options: { stimulusOption: 'foo' }, + errors: [], + }); + }); + + it('parses source with comment syntax error is returned', () => { + const src = '/* stimulusOption: foo" */ export default class extends Controller {}'; + const { errors } = getStimulusCommentOptions(src); + expect(errors).toHaveLength(1); + expect(errors[0].comment.value).toEqual(' stimulusOption: foo" '); + }); + + it('parses source with JavaScript syntax error return empty', () => { + const src = '/* stimulusOption: foo" */ export default class extends Controller }'; + expect(getStimulusCommentOptions(src)).toEqual({ + options: {}, + errors: [], + }); + }); +}); diff --git a/test/webpack/lazy-controller-loader.test.js b/test/webpack/lazy-controller-loader.test.js new file mode 100644 index 0000000..6efa873 --- /dev/null +++ b/test/webpack/lazy-controller-loader.test.js @@ -0,0 +1,50 @@ +/* + * This file is part of the Symfony Webpack Encore package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +const lazyControllerLoader = require('../../dist/webpack/lazy-controller-loader'); + +function callLoader(src, errors = []) { + const loaderThis = { + resource: './some-resource', + emitError(error) { + errors.push(error); + }, + }; + + return lazyControllerLoader.call(loaderThis, src); +} + +describe('lazyControllerLoader', () => { + it('does nothing with a non-lazy controller', () => { + const src = 'export default class extends Controller {}'; + expect(callLoader(src)).toEqual(src); + }); + + it('it exports a lazy controller', () => { + const src = "/* stimulusFetch: 'lazy' */ export default class extends Controller {}"; + // look for a little bit of the lazy controller code + expect(callLoader(src)).toContain('application.register('); + }); + + it('it emits an error on a syntax problem', () => { + const src = '/* stimulusFetch: "lazy */ export default class extends Controller {}'; + const errors = []; + callLoader(src, errors); + expect(errors).toHaveLength(1); + }); + + it('it emits an error on an invalid value', () => { + const src = '/* stimulusFetch: "lazy-once" */ export default class extends Controller {}'; + const errors = []; + callLoader(src, errors); + expect(errors).toHaveLength(1); + }); +});