Skip to content

Commit 0d8a3d7

Browse files
committed
feature #19 Lazy controllers for user-land controllers (weaverryan)
This PR was merged into the main branch. Discussion ---------- Lazy controllers for user-land controllers Hi! This allows users to make their own controllers "lazy" by adding a special comment above their controllers: ```js // assets/controllers/hello_controller.js import { Controller } from 'stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { // ... } ``` When you do this, your controller (and its dependencies) are *not* included in the main `app.js` built file. Instead, they are split into their own chunk/file. This file is loaded asynchronously the moment that the *first* element appears on the page for this controller (e.g. `<div data-controller="hello">`). This follows up on #15 (and only the last commit is new to this PR). The `/* stimulusFetch: 'lazy' */` is inspired by Webpack's `/* webpackMode: 'lazy' */` comments and uses the same mechanism to parse them. To activate this, you only need to process your controllers through a new loader: ```diff // assets/bootstrap.js export const app = startStimulusApp(require.context( - './controllers', + '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', true, /\.(j|t)sx?$/ )); ``` That looks a bit ugly, but this is code that we'll give users via the recipe anyways. I've tested this in a real app and it works beautifully ❤️ . Cheers! Commits ------- bbf8ab0 Implementing a way to make user-land controllers lazy
2 parents 5a7d462 + bbf8ab0 commit 0d8a3d7

10 files changed

+415
-4
lines changed

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
in `controllers.json` to `lazy`, your controller will not
1616
be downloaded until the controller element first appears on the page.
1717

18-
* The `webpackMode` option in `controllers.json` was deprecated. Use
19-
the new `fetch` option instead.
18+
* Support for making your own controllers "lazy" (as described above)
19+
can now be achieved by loading your controllers through the
20+
`@symfony/stimulus-bridge/lazy-controller-loader` loader and
21+
adding a `/* stimulusFetch: 'lazy' */` comment above your controller.
2022

2123
## 1.1.0
2224

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,37 @@ ordered from least to most lazy:
190190
separate file and only downloaded asynchronously if (and when) the `data-controller`
191191
HTML appears on the page.
192192

193+
## Lazy Controllers
194+
195+
You can also make your own controllers "lazy": giving them the same behavior
196+
as the `lazy-controller` explained above. In this case, your controller isn't
197+
downloaded until an element for that controller first appears on the page.
198+
199+
To activate this, first make sure that you're using the special loader -
200+
`@symfony/stimulus-bridge/lazy-controller-loader` - when loading your controllers:
201+
202+
```js
203+
// assets/bootstrap.js
204+
205+
export const app = startStimulusApp(require.context(
206+
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
207+
true,
208+
/\.(j|t)sx?$/
209+
));
210+
```
211+
212+
Next, you can make any controllers lazy by adding a `/* stimulusFetch: 'lazy' */`
213+
comment above that controller:
214+
215+
```js
216+
import { Controller } from 'stimulus';
217+
218+
/* stimulusFetch: 'lazy' */
219+
export default class extends Controller {
220+
// ...
221+
}
222+
```
223+
193224
## Run tests
194225

195226
```sh
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
'use strict';
10+
11+
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; } } }; }
12+
13+
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); }
14+
15+
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; }
16+
17+
var acorn = require('acorn');
18+
19+
var vm = require('vm');
20+
21+
var stimulusCommentRegExp = new RegExp(/(^|\W)stimulus[A-Z]{1,}[A-Za-z]{1,}:/);
22+
var EMPTY_COMMENT_OPTIONS = {
23+
options: {},
24+
errors: []
25+
};
26+
27+
function getCommentsFromSource(source) {
28+
var comments = [];
29+
acorn.parse(source, {
30+
onComment: comments,
31+
sourceType: 'module',
32+
ecmaVersion: 2020
33+
});
34+
return comments;
35+
}
36+
/**
37+
* Inspired by Webpack's JavaScriptParser
38+
*/
39+
40+
41+
module.exports = function parseComments(source) {
42+
var comments;
43+
44+
try {
45+
comments = getCommentsFromSource(source);
46+
} catch (e) {
47+
return EMPTY_COMMENT_OPTIONS;
48+
}
49+
50+
if (comments.length === 0) {
51+
return EMPTY_COMMENT_OPTIONS;
52+
}
53+
54+
var options = {};
55+
var errors = [];
56+
57+
var _iterator = _createForOfIteratorHelper(comments),
58+
_step;
59+
60+
try {
61+
for (_iterator.s(); !(_step = _iterator.n()).done;) {
62+
var comment = _step.value;
63+
var value = comment.value;
64+
65+
if (value && stimulusCommentRegExp.test(value)) {
66+
// try compile only if stimulus options comment is present
67+
try {
68+
var val = vm.runInNewContext("(function(){return {".concat(value, "};})()"));
69+
Object.assign(options, val);
70+
} catch (e) {
71+
e.comment = comment;
72+
errors.push(e);
73+
}
74+
}
75+
}
76+
} catch (err) {
77+
_iterator.e(err);
78+
} finally {
79+
_iterator.f();
80+
}
81+
82+
return {
83+
options: options,
84+
errors: errors
85+
};
86+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
'use strict';
10+
11+
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; } } }; }
12+
13+
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); }
14+
15+
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; }
16+
17+
var generateLazyController = require('./generate-lazy-controller');
18+
19+
var getStimulusCommentOptions = require('../util/get-stimulus-comment-options');
20+
/**
21+
* Loader that can make a Stimulus controller lazy.
22+
*
23+
* This loader is meant to be used to load the Stimulus controllers
24+
* themselves. It detects a stimulusFetch: 'lazy' comment above the
25+
* controller. If present, the controller is replaced by a controller
26+
* that will lazily import the real controller the first time the
27+
* element appears.
28+
*
29+
* @param {string} source of a module that exports a Stimulus controller
30+
* @return {string}
31+
*/
32+
33+
34+
module.exports = function (source) {
35+
var _getStimulusCommentOp = getStimulusCommentOptions(source),
36+
options = _getStimulusCommentOp.options,
37+
errors = _getStimulusCommentOp.errors;
38+
39+
var _iterator = _createForOfIteratorHelper(errors),
40+
_step;
41+
42+
try {
43+
for (_iterator.s(); !(_step = _iterator.n()).done;) {
44+
var error = _step.value;
45+
this.emitError(new Error("Invalid comment found:\n\n \"/* ".concat(error.comment.value.trim(), " */\".\n\nCheck your syntax.")));
46+
}
47+
} catch (err) {
48+
_iterator.e(err);
49+
} finally {
50+
_iterator.f();
51+
}
52+
53+
var stimulusFetch = typeof options.stimulusFetch !== 'undefined' ? options.stimulusFetch : 'eager';
54+
55+
if (!['eager', 'lazy'].includes(stimulusFetch)) {
56+
this.emitError(new Error("Invalid value \"".concat(stimulusFetch, "\" found for \"stimulusFetch\". Allowed values are \"lazy\" or \"eager\"")));
57+
}
58+
59+
var isLazy = stimulusFetch === 'lazy';
60+
61+
if (!isLazy) {
62+
return source;
63+
}
64+
65+
return "import { Controller } from 'stimulus';\nexport default ".concat(generateLazyController(this.resource, 0));
66+
};

lazy-controller-loader.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
module.exports = require('./dist/webpack/lazy-controller-loader');

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
"peerDependencies": {
1616
"stimulus": "^2.0"
1717
},
18-
"dependencies": {},
18+
"dependencies": {
19+
"acorn": "^8.0.5"
20+
},
1921
"devDependencies": {
2022
"@babel/cli": "^7.12.1",
2123
"@babel/core": "^7.12.3",
@@ -35,6 +37,7 @@
3537
"files": [
3638
"src/",
3739
"dist/",
38-
"controllers.json"
40+
"controllers.json",
41+
"lazy-controller-loader.js"
3942
]
4043
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
const acorn = require('acorn');
13+
const vm = require('vm');
14+
15+
const stimulusCommentRegExp = new RegExp(/(^|\W)stimulus[A-Z]{1,}[A-Za-z]{1,}:/);
16+
17+
const EMPTY_COMMENT_OPTIONS = {
18+
options: {},
19+
errors: [],
20+
};
21+
22+
function getCommentsFromSource(source) {
23+
const comments = [];
24+
acorn.parse(source, {
25+
onComment: comments,
26+
sourceType: 'module',
27+
ecmaVersion: 2020,
28+
});
29+
30+
return comments;
31+
}
32+
33+
/**
34+
* Inspired by Webpack's JavaScriptParser
35+
*/
36+
module.exports = function parseComments(source) {
37+
let comments;
38+
try {
39+
comments = getCommentsFromSource(source);
40+
} catch (e) {
41+
return EMPTY_COMMENT_OPTIONS;
42+
}
43+
44+
if (comments.length === 0) {
45+
return EMPTY_COMMENT_OPTIONS;
46+
}
47+
48+
let options = {};
49+
let errors = [];
50+
for (const comment of comments) {
51+
const { value } = comment;
52+
if (value && stimulusCommentRegExp.test(value)) {
53+
// try compile only if stimulus options comment is present
54+
try {
55+
const val = vm.runInNewContext(`(function(){return {${value}};})()`);
56+
Object.assign(options, val);
57+
} catch (e) {
58+
e.comment = comment;
59+
errors.push(e);
60+
}
61+
}
62+
}
63+
return { options, errors };
64+
};

src/webpack/lazy-controller-loader.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
const generateLazyController = require('./generate-lazy-controller');
13+
const getStimulusCommentOptions = require('../util/get-stimulus-comment-options');
14+
15+
/**
16+
* Loader that can make a Stimulus controller lazy.
17+
*
18+
* This loader is meant to be used to load the Stimulus controllers
19+
* themselves. It detects a stimulusFetch: 'lazy' comment above the
20+
* controller. If present, the controller is replaced by a controller
21+
* that will lazily import the real controller the first time the
22+
* element appears.
23+
*
24+
* @param {string} source of a module that exports a Stimulus controller
25+
* @return {string}
26+
*/
27+
module.exports = function (source) {
28+
const { options, errors } = getStimulusCommentOptions(source);
29+
30+
for (const error of errors) {
31+
this.emitError(
32+
new Error(`Invalid comment found:\n\n "/* ${error.comment.value.trim()} */".\n\nCheck your syntax.`)
33+
);
34+
}
35+
36+
const stimulusFetch = typeof options.stimulusFetch !== 'undefined' ? options.stimulusFetch : 'eager';
37+
if (!['eager', 'lazy'].includes(stimulusFetch)) {
38+
this.emitError(
39+
new Error(
40+
`Invalid value "${stimulusFetch}" found for "stimulusFetch". Allowed values are "lazy" or "eager"`
41+
)
42+
);
43+
}
44+
const isLazy = stimulusFetch === 'lazy';
45+
46+
if (!isLazy) {
47+
return source;
48+
}
49+
50+
return `import { Controller } from 'stimulus';
51+
export default ${generateLazyController(this.resource, 0)}`;
52+
};

0 commit comments

Comments
 (0)