Skip to content

Commit f6f4d24

Browse files
committed
Implementing a way to make user-land controllers lazy
1 parent e3b9161 commit f6f4d24

10 files changed

+392
-1
lines changed

CHANGELOG.md

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

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 `/* stimulusLazyController: true */` comment above your controller.
22+
1823
## 1.1.0
1924

2025
* Support for Stimulus 1 dropped and support for Stimulus 2 added - #4.

README.md

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

104+
## Lazy Controllers
105+
106+
You can also make your own controllers "lazy": giving them the same behavior
107+
as the `lazy-controller` explained above. In this case, your controller isn't
108+
downloaded until an element for that controller first appears on the page.
109+
110+
To activate this, first make sure that you're using the special loader -
111+
`@symfony/stimulus-bridge/lazy-controller-loader` - when loading your controllers:
112+
113+
```js
114+
// assets/bootstrap.js
115+
116+
export const app = startStimulusApp(require.context(
117+
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
118+
true,
119+
/\.(j|t)sx?$/
120+
));
121+
```
122+
123+
Next, you can make any controllers lazy by adding a `/* stimulusLazyController: true */`
124+
comment above that controller:
125+
126+
```js
127+
import { Controller } from 'stimulus';
128+
129+
/* stimulusLazyController: true */
130+
export default class extends Controller {
131+
// ...
132+
}
133+
```
134+
104135
## Run tests
105136

106137
```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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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 stimulusLazyController: true 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 isLazy = typeof options.stimulusLazyController !== 'undefined' ? options.stimulusLazyController : false;
54+
55+
if (!isLazy) {
56+
return source;
57+
}
58+
59+
return "import { Controller } from 'stimulus';\nexport default ".concat(generateLazyController(this.resource, 0));
60+
};

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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"files": [
3939
"src/",
4040
"dist/",
41-
"controllers.json"
41+
"controllers.json",
42+
"lazy-controller-loader.js"
4243
]
4344
}
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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 stimulusLazyController: true 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 isLazy = typeof options.stimulusLazyController !== 'undefined' ? options.stimulusLazyController : false;
37+
38+
if (!isLazy) {
39+
return source;
40+
}
41+
42+
return `import { Controller } from 'stimulus';
43+
export default ${generateLazyController(this.resource, 0)}`;
44+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* This file is part of the Symfony Webpack Encore 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 getStimulusCommentOptions = require('../../dist/util/get-stimulus-comment-options');
13+
14+
describe('getStimulusCommentOptions', () => {
15+
it('parses source with no comments', () => {
16+
const src = 'export default class extends Controller {}';
17+
expect(getStimulusCommentOptions(src)).toEqual({
18+
options: {},
19+
errors: []
20+
});
21+
});
22+
23+
it('parses source with matching and non-matching comments', () => {
24+
const src = '/* stimulusOption: "foo" */ /* somethingElse: "bar" */ export default class extends Controller {}';
25+
expect(getStimulusCommentOptions(src)).toEqual({
26+
options: { stimulusOption: 'foo' },
27+
errors: []
28+
});
29+
});
30+
31+
it('parses source with comment syntax error is returned', () => {
32+
const src = '/* stimulusOption: foo" */ export default class extends Controller {}';
33+
const { errors } = getStimulusCommentOptions(src);
34+
expect(errors).toHaveLength(1);
35+
expect(errors[0].comment.value).toEqual(' stimulusOption: foo" ');
36+
});
37+
38+
it('parses source with JavaScript syntax error return empty', () => {
39+
const src = '/* stimulusOption: foo" */ export default class extends Controller }';
40+
expect(getStimulusCommentOptions(src)).toEqual({
41+
options: {},
42+
errors: []
43+
});
44+
});
45+
});

0 commit comments

Comments
 (0)