@schukai/monster 4.154.12 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +8 -0
- package/documentation/migrations/monster-5-options.md +90 -0
- package/package.json +1 -1
- package/source/dom/customelement.mjs +49 -16
- package/source/types/version.mjs +1 -1
- package/test/cases/dom/customelement.mjs +4 -8
- package/test/cases/dom/early-options-contract.mjs +40 -0
- package/test/cases/dom/options-lifecycle.mjs +216 -0
- package/test/cases/packaging.mjs +60 -0
- package/test/util/early-options.mjs +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
## [5.0.1] - 2026-10-11
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- **docs:** ship Monster 5 migration guide ([#597](https://gitlab.schukai.com/oss/libraries/javascript/monster/issues/597))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## [5.0.0] - 2026-10-10
|
|
14
|
+
|
|
15
|
+
### Breaking Changes
|
|
16
|
+
|
|
17
|
+
- **custom-element:** `getOption()`, `setOption()` and `setOptions()` now throw
|
|
18
|
+
`OptionsNotInitializedError` while `defaults` or `customization` are being
|
|
19
|
+
evaluated ([#596](https://gitlab.schukai.com/oss/libraries/javascript/monster/issues/596)).
|
|
20
|
+
Keep both getters and their helpers declarative. Use `initMethodSymbol` for
|
|
21
|
+
initialized constructor options and `assembleMethodSymbol` after late
|
|
22
|
+
attributes or script configuration have been applied. Monster 5 deliberately
|
|
23
|
+
provides no compatibility switch, early fallback or write queue. See the
|
|
24
|
+
[Monster 5 options migration guide](documentation/migrations/monster-5-options.md).
|
|
25
|
+
|
|
5
26
|
## [4.154.12] - 2026-10-10
|
|
6
27
|
|
|
7
28
|
### Bug Fixes
|
package/README.md
CHANGED
|
@@ -25,6 +25,14 @@ For certain functions, additional [polyfills](#polyfill) may be required.
|
|
|
25
25
|
|
|
26
26
|
To access our documentation and examples, visit [monsterjs.org/](https://monsterjs.org/).
|
|
27
27
|
|
|
28
|
+
### Migrating to Monster 5
|
|
29
|
+
|
|
30
|
+
Monster 5 makes the custom-element option lifecycle strict. Option access is
|
|
31
|
+
not available while `defaults` or `customization` are being evaluated. See the
|
|
32
|
+
[Monster 5 options migration guide](documentation/migrations/monster-5-options.md)
|
|
33
|
+
for the lifecycle contract, migration examples and the correct use of
|
|
34
|
+
`initMethodSymbol` and `assembleMethodSymbol`.
|
|
35
|
+
|
|
28
36
|
## Installation
|
|
29
37
|
|
|
30
38
|
Install Monster using npm, Yarn, or pnpm:
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Monster 5: initialized options
|
|
2
|
+
|
|
3
|
+
`CustomElement.getOption()`, `setOption()` and `setOptions()` require an initialized
|
|
4
|
+
option observer. Calls made while the constructor evaluates `defaults` or
|
|
5
|
+
`customization` throw `OptionsNotInitializedError`. The error is exported from
|
|
6
|
+
`source/dom/customelement.mjs` and the main Monster entry point; its `method` and
|
|
7
|
+
`component` properties identify the attempted operation and class.
|
|
8
|
+
|
|
9
|
+
Monster 4.154.10 restored a fallback for early reads. Monster 5 removes that
|
|
10
|
+
fallback. There is no compatibility switch, dependency injection setting or
|
|
11
|
+
queue for early writes. Normal fallback values for missing initialized options
|
|
12
|
+
continue to work, as do `false`, `0`, `""` and `null`.
|
|
13
|
+
|
|
14
|
+
## Lifecycle
|
|
15
|
+
|
|
16
|
+
1. The constructor reads `defaults`, applies available `data-monster-option-*`
|
|
17
|
+
attributes and applies `customization` (evaluated once).
|
|
18
|
+
2. It creates the option observer, then calls `initMethodSymbol`. All three
|
|
19
|
+
option methods are now usable. Derived instance fields are not initialized
|
|
20
|
+
yet: this hook runs inside the base constructor.
|
|
21
|
+
3. On first connection, option attributes are read again. The base assembly
|
|
22
|
+
merges `data-monster-options`, then the script selected by
|
|
23
|
+
`data-monster-options-selector`, before rendering and connecting updaters.
|
|
24
|
+
4. Runtime setters notify observers. `setOptions()` merges without resetting
|
|
25
|
+
defaults. Reconnection rebinds the existing DOM and updater snapshot; it does
|
|
26
|
+
not repeat assembly or reload the JSON/script configuration.
|
|
27
|
+
|
|
28
|
+
Available attributes depend on the creation path. In particular,
|
|
29
|
+
`document.createElement()` cannot see attributes the caller will set later.
|
|
30
|
+
An initialized API is therefore not a promise that all configuration sources
|
|
31
|
+
have already been applied. Existing attribute conversion and precedence remain
|
|
32
|
+
unchanged, including the attribute reread on connection.
|
|
33
|
+
|
|
34
|
+
## Migration
|
|
35
|
+
|
|
36
|
+
This creates a circular dependency and is no longer supported:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
get defaults() {
|
|
40
|
+
return { ...super.defaults, title: this.getOption("labels.title", "Details") };
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Define initial values without reading the options under construction:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
get defaults() {
|
|
48
|
+
return {
|
|
49
|
+
...super.defaults,
|
|
50
|
+
labels: { title: "Details" },
|
|
51
|
+
templates: {
|
|
52
|
+
main: '<h2 data-monster-replace="path:labels.title"></h2>',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Updater-bound markup reads the assembled configuration and follows subsequent
|
|
59
|
+
changes. For imperative work that needs JSON/script options, use the existing
|
|
60
|
+
assembly hook after its `super` call:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
[assembleMethodSymbol]() {
|
|
64
|
+
super[assembleMethodSymbol]();
|
|
65
|
+
this.configureFromResource(this.getOption("resourceConfig"));
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
At that point the base template has already rendered. Do not move template
|
|
71
|
+
configuration after rendering and expect it to rebuild automatically. Prefer a
|
|
72
|
+
static template with updater bindings, or update the rendered component through
|
|
73
|
+
its established API. Do not permanently cache IDs or context keys from
|
|
74
|
+
constructor-time fallbacks.
|
|
75
|
+
|
|
76
|
+
Work needing only constructor configuration can use `initMethodSymbol` after
|
|
77
|
+
its `super` call. Callbacks stored in defaults may read options when invoked
|
|
78
|
+
later; the restriction concerns execution during initialization, not where a
|
|
79
|
+
callback is declared. The same rule applies to helpers called by either getter.
|
|
80
|
+
|
|
81
|
+
Consumer subclasses must migrate before adopting Monster 5. Other option
|
|
82
|
+
classes, including the deprecated `BaseWithOptions`, are outside this change.
|
|
83
|
+
|
|
84
|
+
## Verification
|
|
85
|
+
|
|
86
|
+
Issue #596 adds API lifecycle tests, a local AST call-graph check, and browser
|
|
87
|
+
construction/assembly coverage for the registered component catalog. The static
|
|
88
|
+
check resolves direct calls, local helpers and methods; it is not a proof about
|
|
89
|
+
imported or dynamically selected functions. Browser coverage complements it.
|
|
90
|
+
Existing form, updater, datatable and reconnect suites remain release gates.
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","exports":{".":"./source/monster.mjs","./CHANGELOG.md":"./CHANGELOG.md","./LICENSE":"./LICENSE","./README.md":"./README.md","./package.json":"./package.json","./source/*":"./source/*","./test/*":"./test/*"},"files":["source","test/cases","test/util","CHANGELOG.md"],"homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"sideEffects":["./source/monster.mjs","./source/components/**","./source/i18n/**","./test/**"],"type":"module","version":"
|
|
1
|
+
{"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","exports":{".":"./source/monster.mjs","./CHANGELOG.md":"./CHANGELOG.md","./LICENSE":"./LICENSE","./README.md":"./README.md","./documentation/migrations/monster-5-options.md":"./documentation/migrations/monster-5-options.md","./package.json":"./package.json","./source/*":"./source/*","./test/*":"./test/*"},"files":["source","test/cases","test/util","documentation/migrations/monster-5-options.md","CHANGELOG.md"],"homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"sideEffects":["./source/monster.mjs","./source/components/**","./source/i18n/**","./test/**"],"type":"module","version":"5.0.1"}
|
|
@@ -61,6 +61,7 @@ import { addErrorAttribute } from "./error.mjs";
|
|
|
61
61
|
|
|
62
62
|
export {
|
|
63
63
|
CustomElement,
|
|
64
|
+
OptionsNotInitializedError,
|
|
64
65
|
initMethodSymbol,
|
|
65
66
|
assembleMethodSymbol,
|
|
66
67
|
attributeObserverSymbol,
|
|
@@ -69,6 +70,34 @@ export {
|
|
|
69
70
|
updaterTransformerMethodsSymbol,
|
|
70
71
|
};
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* An options API was used before the element's option observer was created.
|
|
75
|
+
* @summary Reports an option access before CustomElement initialization.
|
|
76
|
+
* @since 5.0.0
|
|
77
|
+
*/
|
|
78
|
+
class OptionsNotInitializedError extends Error {
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} method attempted options API method
|
|
81
|
+
* @param {string} component component constructor name
|
|
82
|
+
*/
|
|
83
|
+
constructor(method, component) {
|
|
84
|
+
super(
|
|
85
|
+
`${component}.${method}() requires initialized options. Do not use the options API while defaults or customization are being evaluated during construction. Use initMethodSymbol for initial options, or assembly for attribute/script configuration.`,
|
|
86
|
+
);
|
|
87
|
+
this.name = "OptionsNotInitializedError";
|
|
88
|
+
this.method = method;
|
|
89
|
+
this.component = component;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireOptionObserver(element, method) {
|
|
94
|
+
const observer = element[internalSymbol];
|
|
95
|
+
if (observer === undefined) {
|
|
96
|
+
throw new OptionsNotInitializedError(method, element.constructor.name);
|
|
97
|
+
}
|
|
98
|
+
return observer;
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
/**
|
|
73
102
|
* @type {symbol}
|
|
74
103
|
*/
|
|
@@ -224,12 +253,15 @@ class RevisionObserver extends Observer {
|
|
|
224
253
|
*/
|
|
225
254
|
class CustomElement extends HTMLElement {
|
|
226
255
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
256
|
+
* Initializes defaults, available option attributes and customization before
|
|
257
|
+
* creating the option observer and calling initMethodSymbol. The options API
|
|
258
|
+
* must not be called while defaults or customization are being evaluated.
|
|
259
|
+
* JSON/script options and DOM rendering are handled later by base assembly.
|
|
229
260
|
*
|
|
230
261
|
* IMPORTANT: CustomControls instances are not created via the constructor, but either via a tag in the HTML or via <code>document.createElement()</code>.
|
|
231
262
|
*
|
|
232
263
|
* @throws {Error} the option attribute does not contain a valid JSON definition.
|
|
264
|
+
* @throws {OptionsNotInitializedError} an options API was called too early
|
|
233
265
|
*/
|
|
234
266
|
constructor() {
|
|
235
267
|
super();
|
|
@@ -247,9 +279,10 @@ class CustomElement extends HTMLElement {
|
|
|
247
279
|
);
|
|
248
280
|
}
|
|
249
281
|
|
|
250
|
-
|
|
282
|
+
const customization = this.customization;
|
|
283
|
+
if (customization instanceof Map) {
|
|
251
284
|
const pathfinder = new Pathfinder(options);
|
|
252
|
-
|
|
285
|
+
customization.forEach((value, key) => {
|
|
253
286
|
if (key === undefined || key === null || key === "") {
|
|
254
287
|
return;
|
|
255
288
|
}
|
|
@@ -333,6 +366,7 @@ class CustomElement extends HTMLElement {
|
|
|
333
366
|
* Unlike the defaults that expect an object, the customization is a Map.
|
|
334
367
|
* This also allows overwriting individual values in a deeper structure
|
|
335
368
|
* without having to redefine the entire structure and thus changing the defaults.
|
|
369
|
+
* Evaluated once during construction, before the options API is available.
|
|
336
370
|
* @return {Map}
|
|
337
371
|
*/
|
|
338
372
|
get customization() {
|
|
@@ -540,20 +574,17 @@ class CustomElement extends HTMLElement {
|
|
|
540
574
|
/**
|
|
541
575
|
* nested options can be specified by path `a.b.c`
|
|
542
576
|
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
* derived `defaults` and `customization` getters during initialization.
|
|
577
|
+
* Options are available from initMethodSymbol onwards. Attribute/script JSON
|
|
578
|
+
* is applied during assembly. The fallback only applies to initialized options.
|
|
546
579
|
*
|
|
547
580
|
* @param {string} path
|
|
548
581
|
* @param {*} defaultValue
|
|
549
582
|
* @return {*}
|
|
583
|
+
* @throws {OptionsNotInitializedError} options have not been initialized
|
|
550
584
|
* @since 1.10.0
|
|
551
585
|
*/
|
|
552
586
|
getOption(path, defaultValue = undefined) {
|
|
553
|
-
const optionObserver = this
|
|
554
|
-
if (optionObserver === undefined) {
|
|
555
|
-
return defaultValue;
|
|
556
|
-
}
|
|
587
|
+
const optionObserver = requireOptionObserver(this, "getOption");
|
|
557
588
|
|
|
558
589
|
return new Pathfinder(optionObserver.getRealSubject().options).getVia(
|
|
559
590
|
path,
|
|
@@ -568,12 +599,12 @@ class CustomElement extends HTMLElement {
|
|
|
568
599
|
* @param {*} value
|
|
569
600
|
* @return {CustomElement}
|
|
570
601
|
* @since 1.14.0
|
|
602
|
+
* @throws {OptionsNotInitializedError} options have not been initialized
|
|
571
603
|
*/
|
|
572
604
|
setOption(path, value) {
|
|
573
|
-
new Pathfinder(
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
);
|
|
605
|
+
new Pathfinder(
|
|
606
|
+
requireOptionObserver(this, "setOption").getSubject().options,
|
|
607
|
+
).setVia(path, value);
|
|
577
608
|
return this;
|
|
578
609
|
}
|
|
579
610
|
|
|
@@ -581,15 +612,17 @@ class CustomElement extends HTMLElement {
|
|
|
581
612
|
* @since 1.15.0
|
|
582
613
|
* @param {string|object} options
|
|
583
614
|
* @return {CustomElement}
|
|
615
|
+
* @throws {OptionsNotInitializedError} options have not been initialized
|
|
584
616
|
*/
|
|
585
617
|
setOptions(options) {
|
|
618
|
+
const optionObserver = requireOptionObserver(this, "setOptions");
|
|
586
619
|
if (isString(options)) {
|
|
587
620
|
options = parseOptionsJSON.call(this, options);
|
|
588
621
|
}
|
|
589
622
|
// 2024-01-21: remove this.defaults, otherwise it will overwrite
|
|
590
623
|
// the current settings that have already been made.
|
|
591
624
|
// https://gitlab.schukai.com/oss/libraries/javascript/monster/-/issues/136
|
|
592
|
-
extend(
|
|
625
|
+
extend(optionObserver.getSubject().options, options);
|
|
593
626
|
|
|
594
627
|
return this;
|
|
595
628
|
}
|
package/source/types/version.mjs
CHANGED
|
@@ -1232,7 +1232,7 @@ describe('DOM', function () {
|
|
|
1232
1232
|
mocks.innerHTML = html1;
|
|
1233
1233
|
})
|
|
1234
1234
|
|
|
1235
|
-
it('should
|
|
1235
|
+
it('should reject option reads while defaults initialize options (#596)', function () {
|
|
1236
1236
|
const htmlTAG = 'monster-testclass-early-option-576';
|
|
1237
1237
|
|
|
1238
1238
|
if (!customElements.get(htmlTAG)) {
|
|
@@ -1270,13 +1270,9 @@ describe('DOM', function () {
|
|
|
1270
1270
|
registerCustomElement(EarlyOptionComponent);
|
|
1271
1271
|
}
|
|
1272
1272
|
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
'defaults-fallback',
|
|
1277
|
-
);
|
|
1278
|
-
expect(element.getOption('fromCustomization')).to.equal(
|
|
1279
|
-
'customization-fallback',
|
|
1273
|
+
const Constructor = customElements.get(htmlTAG);
|
|
1274
|
+
expect(() => new Constructor()).to.throw(
|
|
1275
|
+
'getOption() requires initialized options',
|
|
1280
1276
|
);
|
|
1281
1277
|
});
|
|
1282
1278
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { globSync } from "glob";
|
|
4
|
+
import { findEarlyOptions } from "../../util/early-options.mjs";
|
|
5
|
+
|
|
6
|
+
describe("Early options source contract (#596)", () => {
|
|
7
|
+
it("rejects synchronous helper and method calls but allows deferred callbacks", () => {
|
|
8
|
+
const source = `
|
|
9
|
+
function helper() { return this.getOption("value"); }
|
|
10
|
+
class Example {
|
|
11
|
+
get defaults() { return { value: helper.call(this), later: () => this.getOption("later") }; }
|
|
12
|
+
get customization() { this.configure(); return new Map(); }
|
|
13
|
+
configure() { this.setOptions({}); }
|
|
14
|
+
}`;
|
|
15
|
+
assert.deepEqual(
|
|
16
|
+
findEarlyOptions(source).map(({ phase, method }) => ({ phase, method })),
|
|
17
|
+
[
|
|
18
|
+
{ phase: "defaults", method: "getOption" },
|
|
19
|
+
{ phase: "customization", method: "setOptions" },
|
|
20
|
+
],
|
|
21
|
+
);
|
|
22
|
+
assert.equal(
|
|
23
|
+
findEarlyOptions(
|
|
24
|
+
'class Example { get defaults() { return (() => this.setOption("x", 1))(); } }',
|
|
25
|
+
).length,
|
|
26
|
+
1,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
it("has no statically resolved early options access in library getters", () => {
|
|
30
|
+
const root = new URL("../../../", import.meta.url);
|
|
31
|
+
const files = globSync("source/**/*.mjs", { cwd: root }).sort();
|
|
32
|
+
assert.ok(files.length > 300);
|
|
33
|
+
const failures = files.flatMap((file) =>
|
|
34
|
+
findEarlyOptions(readFileSync(new URL(file, root), "utf8")).map(
|
|
35
|
+
(failure) => ({ file, ...failure }),
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
assert.deepEqual(failures, []);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
import { initJSDOM } from "../../util/jsdom.mjs";
|
|
3
|
+
|
|
4
|
+
describe("Strict options lifecycle (#596)", function () {
|
|
5
|
+
let api;
|
|
6
|
+
let fixture;
|
|
7
|
+
let sequence = 0;
|
|
8
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
|
|
9
|
+
|
|
10
|
+
before(async () => {
|
|
11
|
+
await initJSDOM({});
|
|
12
|
+
api = await import("../../../source/dom/customelement.mjs");
|
|
13
|
+
});
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
fixture = document.createElement("div");
|
|
16
|
+
document.getElementById("mocks").append(fixture);
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => fixture.remove());
|
|
19
|
+
|
|
20
|
+
function register(Constructor) {
|
|
21
|
+
const tag = `monster-options-596-${++sequence}`;
|
|
22
|
+
customElements.define(tag, Constructor);
|
|
23
|
+
return tag;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const phase of ["defaults", "customization"]) {
|
|
27
|
+
for (const [method, args] of [
|
|
28
|
+
["getOption", ["value", "fallback"]],
|
|
29
|
+
["setOption", ["value", "early"]],
|
|
30
|
+
["setOptions", [{ value: "early" }]],
|
|
31
|
+
["setOptions", ["invalid JSON"]],
|
|
32
|
+
]) {
|
|
33
|
+
it(`rejects ${method}(${JSON.stringify(args)}) in ${phase}`, () => {
|
|
34
|
+
class EarlyOptions extends api.CustomElement {}
|
|
35
|
+
Object.defineProperty(EarlyOptions.prototype, phase, {
|
|
36
|
+
get() {
|
|
37
|
+
return this[method](...args);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
register(EarlyOptions);
|
|
41
|
+
let failure;
|
|
42
|
+
try {
|
|
43
|
+
new EarlyOptions();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
failure = error;
|
|
46
|
+
}
|
|
47
|
+
expect(failure).to.be.instanceOf(api.OptionsNotInitializedError);
|
|
48
|
+
expect(failure.name).to.equal("OptionsNotInitializedError");
|
|
49
|
+
expect(failure.method).to.equal(method);
|
|
50
|
+
expect(failure.component).to.equal("EarlyOptions");
|
|
51
|
+
expect(failure.message).to.include("initMethodSymbol");
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
it("evaluates customization once and makes reads and writes available in init", () => {
|
|
57
|
+
let evaluations = 0;
|
|
58
|
+
class Initialized extends api.CustomElement {
|
|
59
|
+
get defaults() {
|
|
60
|
+
return { ...super.defaults, value: "default", nested: { kept: true } };
|
|
61
|
+
}
|
|
62
|
+
get customization() {
|
|
63
|
+
evaluations++;
|
|
64
|
+
return new Map([["value", "custom"]]);
|
|
65
|
+
}
|
|
66
|
+
[api.initMethodSymbol]() {
|
|
67
|
+
super[api.initMethodSymbol]();
|
|
68
|
+
expect(this.getOption("value")).to.equal("custom");
|
|
69
|
+
expect(this.setOption("nested.added", 1)).to.equal(this);
|
|
70
|
+
expect(this.setOptions('{"nested":{"other":2}}')).to.equal(this);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const element = document.createElement(register(Initialized));
|
|
74
|
+
expect(element).to.be.instanceOf(Initialized);
|
|
75
|
+
expect(evaluations).to.equal(1);
|
|
76
|
+
expect(element.getOption("nested")).to.deep.equal({
|
|
77
|
+
kept: true,
|
|
78
|
+
added: 1,
|
|
79
|
+
other: 2,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("preserves fallback and falsy values, and propagates unrelated failures", () => {
|
|
84
|
+
class Options extends api.CustomElement {}
|
|
85
|
+
const element = document.createElement(register(Options));
|
|
86
|
+
for (const value of [null, false, 0, ""]) {
|
|
87
|
+
element.setOption("value", value);
|
|
88
|
+
expect(element.getOption("value", "fallback")).to.equal(value);
|
|
89
|
+
}
|
|
90
|
+
element.setOption("value", undefined);
|
|
91
|
+
expect(element.getOption("value", "fallback")).to.equal("fallback");
|
|
92
|
+
expect(element.getOption("missing.deep", "fallback")).to.equal("fallback");
|
|
93
|
+
expect(element.getOption("missing.deep")).to.equal(undefined);
|
|
94
|
+
expect(() => element.setOptions("invalid JSON")).to.throw();
|
|
95
|
+
const failure = new Error("property failure");
|
|
96
|
+
element.setOption(
|
|
97
|
+
"broken",
|
|
98
|
+
Object.defineProperty({}, "value", {
|
|
99
|
+
get() {
|
|
100
|
+
throw failure;
|
|
101
|
+
},
|
|
102
|
+
enumerable: true,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
expect(() => element.getOption("broken.value", "fallback")).to.throw(
|
|
106
|
+
failure,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("preserves attribute/customization precedence on upgrade and permits deferred defaults callbacks", async () => {
|
|
111
|
+
class Upgraded extends api.CustomElement {
|
|
112
|
+
get defaults() {
|
|
113
|
+
return {
|
|
114
|
+
...super.defaults,
|
|
115
|
+
value: "default",
|
|
116
|
+
fromAttribute: "default",
|
|
117
|
+
readLater: () => this.getOption("value"),
|
|
118
|
+
templates: { main: "<span></span>" },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
get customization() {
|
|
122
|
+
return new Map([["value", "custom"]]);
|
|
123
|
+
}
|
|
124
|
+
[api.initMethodSymbol]() {
|
|
125
|
+
super[api.initMethodSymbol]();
|
|
126
|
+
this.initialValue = this.getOption("value");
|
|
127
|
+
this.initialAttribute = this.getOption("fromAttribute");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const tag = `monster-options-596-${++sequence}`;
|
|
131
|
+
const holder = document.createElement("div");
|
|
132
|
+
holder.innerHTML = `<${tag} data-monster-option-value="attribute" data-monster-option-from-attribute="present"></${tag}>`;
|
|
133
|
+
const element = holder.firstElementChild;
|
|
134
|
+
customElements.define(tag, Upgraded);
|
|
135
|
+
customElements.upgrade(element);
|
|
136
|
+
expect(element).to.be.instanceOf(Upgraded);
|
|
137
|
+
expect(element.initialValue).to.equal("custom");
|
|
138
|
+
expect(element.initialAttribute).to.equal("present");
|
|
139
|
+
expect(element.getOption("readLater")()).to.equal("custom");
|
|
140
|
+
fixture.append(element);
|
|
141
|
+
await tick();
|
|
142
|
+
expect(element.getOption("readLater")()).to.equal("attribute");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("applies late attributes, JSON and script before rendering; reconnect keeps state", async () => {
|
|
146
|
+
let assemblies = 0;
|
|
147
|
+
class Configured extends api.CustomElement {
|
|
148
|
+
get defaults() {
|
|
149
|
+
return {
|
|
150
|
+
...super.defaults,
|
|
151
|
+
value: "default",
|
|
152
|
+
flag: false,
|
|
153
|
+
count: 0,
|
|
154
|
+
templates: {
|
|
155
|
+
main: '<span data-monster-replace="path:value"></span>',
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
get customization() {
|
|
160
|
+
return new Map([["value", "custom"]]);
|
|
161
|
+
}
|
|
162
|
+
[api.initMethodSymbol]() {
|
|
163
|
+
super[api.initMethodSymbol]();
|
|
164
|
+
this.initialValue = this.getOption("value");
|
|
165
|
+
}
|
|
166
|
+
[api.assembleMethodSymbol]() {
|
|
167
|
+
assemblies++;
|
|
168
|
+
super[api.assembleMethodSymbol]();
|
|
169
|
+
this.assembledValue = this.getOption("value");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const element = document.createElement(register(Configured));
|
|
173
|
+
expect(element.initialValue).to.equal("custom");
|
|
174
|
+
element.setOption("value", "runtime-before-connect");
|
|
175
|
+
element.setAttribute("data-monster-option-value", "attribute");
|
|
176
|
+
element.setAttribute("data-monster-option-flag", "true");
|
|
177
|
+
element.setAttribute("data-monster-option-count", "7");
|
|
178
|
+
element.setAttribute("data-monster-options", '{"value":"json"}');
|
|
179
|
+
const script = document.createElement("script");
|
|
180
|
+
script.type = "application/json";
|
|
181
|
+
script.id = "options-script-596";
|
|
182
|
+
script.textContent = '{"value":"script"}';
|
|
183
|
+
fixture.append(script);
|
|
184
|
+
element.setAttribute(
|
|
185
|
+
"data-monster-options-selector",
|
|
186
|
+
"#options-script-596",
|
|
187
|
+
);
|
|
188
|
+
fixture.append(element);
|
|
189
|
+
await tick();
|
|
190
|
+
expect(element.assembledValue).to.equal("script");
|
|
191
|
+
expect(element.getOption("flag")).to.equal(true);
|
|
192
|
+
expect(element.getOption("count")).to.equal(7);
|
|
193
|
+
expect(element.shadowRoot.querySelector("span").textContent).to.equal(
|
|
194
|
+
"script",
|
|
195
|
+
);
|
|
196
|
+
element.removeAttribute("data-monster-option-value");
|
|
197
|
+
await tick();
|
|
198
|
+
element.setOption("value", "runtime");
|
|
199
|
+
await tick();
|
|
200
|
+
expect(element.shadowRoot.querySelector("span").textContent).to.equal(
|
|
201
|
+
"runtime",
|
|
202
|
+
);
|
|
203
|
+
element.remove();
|
|
204
|
+
script.textContent = '{"value":"must-not-reload"}';
|
|
205
|
+
fixture.append(element);
|
|
206
|
+
await tick();
|
|
207
|
+
expect(assemblies).to.equal(1);
|
|
208
|
+
expect(element.getOption("value")).to.equal("runtime");
|
|
209
|
+
element.setOption("value", "reconnected");
|
|
210
|
+
await tick();
|
|
211
|
+
expect(element.shadowRoot.querySelector("span").textContent).to.equal(
|
|
212
|
+
"reconnected",
|
|
213
|
+
);
|
|
214
|
+
expect(element.hasAttribute("data-monster-error")).to.equal(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
package/test/cases/packaging.mjs
CHANGED
|
@@ -27,6 +27,10 @@ function readBuildNix() {
|
|
|
27
27
|
return readFileSync(join(projectRoot, "nix/scripts/build.nix"), "utf8");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function readProjectFile(path) {
|
|
31
|
+
return readFileSync(join(projectRoot, path), "utf8");
|
|
32
|
+
}
|
|
33
|
+
|
|
30
34
|
describe("Packaging issue #577", function () {
|
|
31
35
|
describe("exports map", function () {
|
|
32
36
|
it("should resolve every literal export target to an existing file", function () {
|
|
@@ -161,6 +165,62 @@ describe("Packaging issue #577", function () {
|
|
|
161
165
|
).to.be.true;
|
|
162
166
|
}
|
|
163
167
|
});
|
|
168
|
+
|
|
169
|
+
it("should ship and export the Monster 5 migration guide", function () {
|
|
170
|
+
const migrationPath = "documentation/migrations/monster-5-options.md";
|
|
171
|
+
const pkg = readPackageJson();
|
|
172
|
+
const buildNix = readBuildNix();
|
|
173
|
+
|
|
174
|
+
expect(pkg.files).to.include(migrationPath);
|
|
175
|
+
expect(pkg.exports[`./${migrationPath}`]).to.equal(`./${migrationPath}`);
|
|
176
|
+
expect(existsSync(join(projectRoot, migrationPath))).to.be.true;
|
|
177
|
+
expect(buildNix).to.include(migrationPath);
|
|
178
|
+
expect(buildNix).to.include(`package/${migrationPath}`);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("Monster 5 migration contract", function () {
|
|
183
|
+
it("should document the strict lifecycle and migration phases", function () {
|
|
184
|
+
const guide = readProjectFile(
|
|
185
|
+
"documentation/migrations/monster-5-options.md",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
for (const contract of [
|
|
189
|
+
"OptionsNotInitializedError",
|
|
190
|
+
"getOption()",
|
|
191
|
+
"setOption()",
|
|
192
|
+
"setOptions()",
|
|
193
|
+
"defaults",
|
|
194
|
+
"customization",
|
|
195
|
+
"document.createElement()",
|
|
196
|
+
"initMethodSymbol",
|
|
197
|
+
"assembleMethodSymbol",
|
|
198
|
+
"There is no compatibility switch",
|
|
199
|
+
]) {
|
|
200
|
+
expect(guide, `migration guide is missing ${contract}`).to.include(
|
|
201
|
+
contract,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("should describe the breaking change in the 5.0.0 changelog", function () {
|
|
207
|
+
const changelog = readProjectFile("CHANGELOG.md");
|
|
208
|
+
const release = changelog
|
|
209
|
+
.split("## [5.0.0]")[1]
|
|
210
|
+
.split("## [4.154.12]")[0];
|
|
211
|
+
|
|
212
|
+
for (const contract of [
|
|
213
|
+
"### Breaking Changes",
|
|
214
|
+
"OptionsNotInitializedError",
|
|
215
|
+
"initMethodSymbol",
|
|
216
|
+
"assembleMethodSymbol",
|
|
217
|
+
"documentation/migrations/monster-5-options.md",
|
|
218
|
+
]) {
|
|
219
|
+
expect(release, `5.0.0 changelog is missing ${contract}`).to.include(
|
|
220
|
+
contract,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
164
224
|
});
|
|
165
225
|
|
|
166
226
|
describe("generated barrel", function () {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { parse } from "espree";
|
|
2
|
+
|
|
3
|
+
const optionMethods = new Set(["getOption", "setOption", "setOptions"]);
|
|
4
|
+
const functions = new Set([
|
|
5
|
+
"FunctionDeclaration",
|
|
6
|
+
"FunctionExpression",
|
|
7
|
+
"ArrowFunctionExpression",
|
|
8
|
+
]);
|
|
9
|
+
const children = (node) =>
|
|
10
|
+
Object.values(node)
|
|
11
|
+
.flat()
|
|
12
|
+
.filter((value) => value && typeof value.type === "string");
|
|
13
|
+
const property = (node) =>
|
|
14
|
+
node.computed ? node.property?.value : node.property?.name;
|
|
15
|
+
|
|
16
|
+
// A conservative local call-graph check. Imported/dynamic dispatch is covered
|
|
17
|
+
// by the actual component construction tests, not claimed as statically proven.
|
|
18
|
+
export function findEarlyOptions(source) {
|
|
19
|
+
const ast = parse(source, {
|
|
20
|
+
ecmaVersion: "latest",
|
|
21
|
+
sourceType: "module",
|
|
22
|
+
loc: true,
|
|
23
|
+
});
|
|
24
|
+
const helpers = new Map();
|
|
25
|
+
const getters = [];
|
|
26
|
+
function collect(node, methods = new Map()) {
|
|
27
|
+
if (node.type === "ClassBody") {
|
|
28
|
+
methods = new Map(
|
|
29
|
+
node.body
|
|
30
|
+
.filter((entry) => !entry.computed && entry.value)
|
|
31
|
+
.map((entry) => [entry.key.name, entry.value]),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (node.type === "FunctionDeclaration" && node.id)
|
|
35
|
+
helpers.set(node.id.name, node);
|
|
36
|
+
if (node.type === "VariableDeclarator" && functions.has(node.init?.type))
|
|
37
|
+
helpers.set(node.id.name, node.init);
|
|
38
|
+
if (
|
|
39
|
+
node.type === "MethodDefinition" &&
|
|
40
|
+
node.kind === "get" &&
|
|
41
|
+
["defaults", "customization"].includes(node.key.name)
|
|
42
|
+
) {
|
|
43
|
+
getters.push({ node, methods });
|
|
44
|
+
}
|
|
45
|
+
for (const child of children(node)) collect(child, methods);
|
|
46
|
+
}
|
|
47
|
+
collect(ast);
|
|
48
|
+
const failures = [];
|
|
49
|
+
for (const { node: getter, methods } of getters) {
|
|
50
|
+
const visited = new Set();
|
|
51
|
+
function execute(fn) {
|
|
52
|
+
if (!fn || visited.has(fn)) return;
|
|
53
|
+
visited.add(fn);
|
|
54
|
+
walk(fn.body);
|
|
55
|
+
}
|
|
56
|
+
function walk(node) {
|
|
57
|
+
if (!node || functions.has(node.type)) return; // callbacks are deferred unless invoked
|
|
58
|
+
if (node.type === "CallExpression") {
|
|
59
|
+
let callee = node.callee;
|
|
60
|
+
if (
|
|
61
|
+
callee.type === "MemberExpression" &&
|
|
62
|
+
["call", "apply"].includes(property(callee))
|
|
63
|
+
)
|
|
64
|
+
callee = callee.object;
|
|
65
|
+
if (callee.type === "MemberExpression") {
|
|
66
|
+
const name = property(callee);
|
|
67
|
+
if (optionMethods.has(name))
|
|
68
|
+
failures.push({
|
|
69
|
+
phase: getter.key.name,
|
|
70
|
+
method: name,
|
|
71
|
+
line: node.loc.start.line,
|
|
72
|
+
});
|
|
73
|
+
if (callee.object.type === "ThisExpression")
|
|
74
|
+
execute(methods.get(name));
|
|
75
|
+
} else if (callee.type === "Identifier")
|
|
76
|
+
execute(helpers.get(callee.name));
|
|
77
|
+
else if (functions.has(callee.type)) execute(callee);
|
|
78
|
+
}
|
|
79
|
+
for (const child of children(node)) walk(child);
|
|
80
|
+
}
|
|
81
|
+
execute(getter.value);
|
|
82
|
+
}
|
|
83
|
+
return failures;
|
|
84
|
+
}
|