solarite 0.7.1 → 0.9.0

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.
@@ -4,6 +4,7 @@ import delve, {isDelvePath} from "./delve.js";
4
4
  import assert from "./assert.js";
5
5
  import PathToAttribValue from "./PathToAttribValue.js";
6
6
  import PathToKey from "./PathToKey.js";
7
+ import PathToEvent from "./PathToEvent.js";
7
8
  import Globals from "./Globals.js";
8
9
  import {getObjectHash} from "./Template.js";
9
10
 
@@ -23,15 +24,15 @@ export default class PathToComponent extends Path {
23
24
  * Call render() on the component pointed to by this Path.
24
25
  * And instantiate it (from a -solarite-placeholder element) if it hasn't been done yet.
25
26
  * @param exprs {Expr[][]} Expressions to evaluate for each attribute to pass to the constructor.
26
- * This is different than other Path.apply() functions which only receive Expr[] and not Expr[][].
27
+ * This is different than other Path.applyAll() functions which only receive Expr[] and not Expr[][].
27
28
  * Because here we're receiving an array of arrays of expressions, one for each dynamic attribute. */
28
- apply(exprs) {
29
- //#IFDEV
29
+ applyAll(exprs) {
30
+ //#IFDEBUG
30
31
  assert(Array.isArray(exprs));
31
32
  assert(!exprs.length || Array.isArray(exprs[0]));
32
33
  //#ENDIF
33
34
 
34
- //#IFDEV
35
+ //#IFDEBUG
35
36
  assert(exprs.length === this.attribPaths.length);
36
37
  //#ENDIF
37
38
 
@@ -47,8 +48,15 @@ export default class PathToComponent extends Path {
47
48
  for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
48
49
  if (attribPath instanceof PathToKey) // The list key is never a component arg.
49
50
  continue;
51
+ // Event attributes like onchange=${...} are bound with addEventListener when the
52
+ // PathToEvent itself is applied. They must not also become constructor fields:
53
+ // a component that assigns its fields onto itself would set the native on*
54
+ // property, making the handler fire a second time with only the (event) argument
55
+ // instead of Solarite's documented (event, element) signature.
56
+ if (attribPath instanceof PathToEvent)
57
+ continue;
50
58
  if (attribPath instanceof PathToAttribValue) {
51
- let name = Util.dashesToCamel(attribPath.attrName);
59
+ let name = Util.dashesToCamel(attribPath.attribName);
52
60
 
53
61
  // Resolve two way bindimg path before we pass it to the component.
54
62
  let value = attribPath.getValue(exprs[i]);
@@ -70,85 +78,120 @@ export default class PathToComponent extends Path {
70
78
  }
71
79
  }
72
80
 
73
- // 2. Instantiate component on first time.
74
- let isAttrib = el.getAttribute('_is');
75
- if (el.tagName.endsWith('-SOLARITE-PLACEHOLDER') || isAttrib) {
81
+ // Constructing a component runs arbitrary user code -- field initializers, the
82
+ // constructor body, render() -- and that code can build more components, re-entering
83
+ // this method and overwriting the hand-off parked below. Saving the caller's value
84
+ // here and restoring it in the finally makes the JS call stack the stack this hand-off
85
+ // needs, and unlike an explicit stack it cannot leak if construction throws.
86
+ let prevSlotChildren = Globals.currentSlotChildren;
87
+ try {
88
+ // 2. Instantiate component on first time.
89
+ let isAttrib = el.getAttribute('_is');
90
+ if (el.tagName.endsWith('-SOLARITE-PLACEHOLDER') || isAttrib) {
91
+
92
+
93
+ // 2a. Instantiate component
94
+ let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
95
+ let Constructor = customElements.get(tagName);
96
+
97
+ // Not defined yet (e.g. the module is being lazily imported): keep the placeholder
98
+ // and instantiate when the definition lands, like a native custom-element upgrade.
99
+ // deferredExprs always holds the LATEST exprs so re-renders while undefined win.
100
+ if (!Constructor) {
101
+ this.deferredExprs = exprs;
102
+ if (!this.whenDefinedPending) {
103
+ this.whenDefinedPending = true;
104
+ console.warn(`Solarite: <${tagName}> is not defined yet; waiting for customElements.define().`);
105
+ customElements.whenDefined(tagName).then(() => {
106
+ this.whenDefinedPending = false;
107
+ let deferred = this.deferredExprs;
108
+ this.deferredExprs = null;
109
+ // Skip if a newer render already instantiated or replaced the placeholder.
110
+ if (deferred && this.nodeMarker === el && el.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
111
+ this.applyAll(deferred);
112
+ });
113
+ }
114
+ return;
115
+ }
76
116
 
117
+ // Hand the children declared inside the component's tag to the RootNodeGroup that
118
+ // its render() is about to create. There is no other channel: the children have
119
+ // to be parked before new Constructor(), because a Solarite constructor may call
120
+ // this.render() itself, and the element that would otherwise carry them does not
121
+ // exist yet.
122
+ Globals.currentSlotChildren = {Constructor, nodes: [...el.childNodes]};
123
+ let newEl = new Constructor(attribs);
124
+
125
+ // 2b. Copy attributes over.
126
+ if (isAttrib) {
127
+ newEl.setAttribute('is', isAttrib);
128
+ // el.removeAttribute('_is');
129
+ }
130
+ for (let attrib of el.attributes)
131
+ if (attrib.name !== '_is')
132
+ newEl.setAttribute(attrib.name, attrib.value);
133
+
134
+ // Set dynamic attributes if they are primitive types.
135
+ for (let name in attribs) {
136
+ let val = attribs[name];
137
+ let valType = typeof val;
138
+ // Only true and false can reach here, so the undefined/null halves of the
139
+ // falsy test this used to spell out could never have decided anything.
140
+ if (valType === 'boolean') {
141
+ if (val)
142
+ newEl.setAttribute(name, '');
143
+ }
144
+
145
+ // If type is a non-boolean primitive, set the attribute value.
146
+ else if (valType==='string' || valType === 'number' || valType==='bigint')
147
+ newEl.setAttribute(name, val);
148
+ }
77
149
 
78
- // 2a. Instantiate component
79
- let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
80
- let Constructor = customElements.get(tagName);
81
- if (!Constructor)
82
- throw new Error(`Must call customElements.define('${tagName}', Class) before using it.`);
83
150
 
84
- Globals.currentSlotChildren = [...el.childNodes]; // TODO: Does this need to be a stack?
85
- let newEl = new Constructor(attribs);
151
+ // 2c. If an id pointed at the placeholder, update it to point to the new element.
152
+ let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
153
+ if (id)
154
+ delve(this.parentNg.getRootEl(), id.split(/\./g), newEl);
86
155
 
87
- // 2b. Copy attributes over.
88
- if (isAttrib) {
89
- newEl.setAttribute('is', isAttrib);
90
- // el.removeAttribute('_is');
91
- }
92
- for (let attrib of el.attributes)
93
- if (attrib.name !== '_is')
94
- newEl.setAttribute(attrib.name, attrib.value);
95
-
96
- // Set dynamic attributes if they are primitive types.
97
- for (let name in attribs) {
98
- let val = attribs[name];
99
- let valType = typeof val;
100
- if (valType === 'boolean') {
101
- if (val !== false && val !== undefined && val !== null) // Util.isFalsy() inlined
102
- newEl.setAttribute(name, '');
156
+ // 2d. Update paths to use replaced element.
157
+ let ng = this.parentNg;
158
+ this.nodeMarker = newEl;
159
+ for (let path of ng.paths) {
160
+ if (path.nodeMarker === el)
161
+ path.nodeMarker = newEl;
162
+ if (path.nodeBefore === el)
163
+ path.nodeBefore = newEl;
164
+ }
165
+ if (ng.startNode === el)
166
+ ng.startNode = newEl;
167
+ if (ng.endNode === el)
168
+ ng.endNode = newEl;
169
+
170
+ // 2f. Call render() if it wasn't called by the constructor.
171
+ // This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
172
+ // Because that path renders it without the attribute expressions.
173
+ if (typeof newEl.render === 'function' && !Globals.rendered.has(newEl))
174
+ newEl.render(attribs, true);
175
+
176
+ // 2g. Update attribute paths to use the new element and re-apply them.
177
+ for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
178
+ attribPath.parentNg = this.parentNg;
179
+ attribPath.nodeMarker = newEl;
180
+ attribPath.applyAll(exprs[i]);
103
181
  }
104
182
 
105
- // If type is a non-boolean primitive, set the attribute value.
106
- else if (valType==='string' || valType === 'number' || valType==='bigint')
107
- newEl.setAttribute(name, val);
183
+ // 2e. Swap it to the DOM.
184
+ el.replaceWith(newEl);
108
185
  }
109
186
 
187
+ // 2f. Render
188
+ else if (typeof el.render === 'function')
189
+ el.render(attribs, changed);
110
190
 
111
- // 2c. If an id pointed at the placeholder, update it to point to the new element.
112
- let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
113
- if (id)
114
- delve(this.parentNg.getRootNode(), id.split(/\./g), newEl);
115
-
116
- // 2d. Update paths to use replaced element.
117
- let ng = this.parentNg;
118
- this.nodeMarker = newEl;
119
- for (let path of ng.paths) {
120
- if (path.nodeMarker === el)
121
- path.nodeMarker = newEl;
122
- if (path.nodeBefore === el)
123
- path.nodeBefore = newEl;
124
- }
125
- if (ng.startNode === el)
126
- ng.startNode = newEl;
127
- if (ng.endNode === el)
128
- ng.endNode = newEl;
129
-
130
- // 2f. Call render() if it wasn't called by the constructor.
131
- // This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
132
- // Because that path renders it without the attribute expressions.
133
- if (typeof newEl.render === 'function' && !Globals.rendered.has(newEl))
134
- newEl.render(attribs, true);
135
-
136
- // 2g. Update attribute paths to use the new element and re-apply them.
137
- for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
138
- attribPath.parentNg = this.parentNg;
139
- attribPath.nodeMarker = newEl;
140
- attribPath.apply(exprs[i]);
141
- }
142
-
143
- // 2e. Swap it to the DOM.
144
- el.replaceWith(newEl);
145
191
  }
146
-
147
- // 2f. Render
148
- else if (typeof el.render === 'function')
149
- el.render(attribs, changed);
150
-
151
- Globals.currentSlotChildren = null;
192
+ finally {
193
+ Globals.currentSlotChildren = prevSlotChildren;
194
+ }
152
195
  }
153
196
 
154
197
  /**
@@ -156,21 +199,16 @@ export default class PathToComponent extends Path {
156
199
  * @param pathOffset {int}
157
200
  * @return {Path} */
158
201
  clone(newRoot, pathOffset=0) {
159
- /*#IFDEV*/this.verify();/*#ENDIF*/
160
- let nodeMarker = this.getNewNodeMarker(newRoot, pathOffset);
161
- let result = new PathToComponent(null, nodeMarker);
202
+ // A component path's nodeBefore is always null (the constructor discards it), so the
203
+ // base clone() resolves only the nodeMarker and hands back a new PathToComponent.
204
+ let result = super.clone(newRoot, pathOffset);
162
205
  result.attribPaths = this.attribPaths.map(path => path.clone(newRoot, pathOffset));
163
-
164
- //#IFDEV
165
- result.verify();
166
- //#ENDIF
167
-
168
206
  return result;
169
207
  }
170
208
 
171
209
  getExpressionCount() { return 0 }
172
210
 
173
- //#IFDEV
211
+ //#IFDEBUG
174
212
  verify() {
175
213
  super.verify();
176
214
  assert(this.nodeMarker.nodeType === Node.ELEMENT_NODE);
@@ -1,15 +1,35 @@
1
1
  import assert from "./assert.js";
2
- import PathToAttribValue from "./PathToAttribValue.js";
2
+ import PathToAttribValue, {delegatedKeyFor} from "./PathToAttribValue.js";
3
+ import {nativeEventPrefix} from "./Util.js";
3
4
 
4
5
  // TODO: Merge this into PathToAttribValue?
5
6
  export default class PathToEvent extends PathToAttribValue {
6
7
 
7
- /** @type {string} The attrName without the "on" prefix. */
8
+ /** @type {string} The attribName without the "on" prefix. */
8
9
  eventName;
9
10
 
10
- constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
11
- super(null, nodeMarker, attrName, attrValue);
12
- this.eventName = attrName ? attrName.slice(2) : null;
11
+ /** @type {symbol|undefined} Expando key nodes store this event's delegated handler under.
12
+ * Undefined for non-delegatable (non-bubbling) events; bindEvent() then binds directly. */
13
+ delegatedKey;
14
+
15
+ /** @type {boolean} True for `native:onclick`: the handler is registered with addEventListener
16
+ * when the template renders, so it runs at its element's own turn in the browser's dispatch
17
+ * order instead of being delegated to the component root. */
18
+ native;
19
+
20
+ constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
21
+ super(null, nodeMarker, attribName, attrValue);
22
+ this.skipIfSame = true;
23
+ let name = attribName;
24
+ this.native = name !== null && name.startsWith(nativeEventPrefix);
25
+ if (this.native)
26
+ name = name.slice(nativeEventPrefix.length);
27
+ this.eventName = name ? name.slice(2) : null;
28
+
29
+ // A native binding leaves delegatedKey undefined. That is the single switch both
30
+ // bindEvent() and the compiled stamp program test to choose the direct
31
+ // addEventListener path, so nothing else has to know about the prefix.
32
+ this.delegatedKey = (this.eventName !== null && !this.native) ? delegatedKeyFor(this.eventName) : undefined;
13
33
  }
14
34
 
15
35
  /**
@@ -19,8 +39,8 @@ export default class PathToEvent extends PathToAttribValue {
19
39
  * onclick=${[this, 'doSomething', 'meow']}
20
40
  *
21
41
  * @param exprs {Expr[]} Only the first is used.*/
22
- apply(exprs) {
23
- //#IFDEV
42
+ applyAll(exprs) {
43
+ //#IFDEBUG
24
44
  assert(Array.isArray(exprs));
25
45
  //#ENDIF
26
46
 
@@ -28,7 +48,7 @@ export default class PathToEvent extends PathToAttribValue {
28
48
  // We have expressions within a string attribute value that's not a Solarite event. E.g.
29
49
  // <div onclick="alert(${1});"
30
50
  if (this.attrValue?.length > 1) {
31
- super.apply(exprs);
51
+ super.applyAll(exprs);
32
52
  return;
33
53
  }
34
54
 
@@ -40,16 +60,16 @@ export default class PathToEvent extends PathToAttribValue {
40
60
  applySingle(expr) {
41
61
  // Expressions within a string attribute value that's not a Solarite event.
42
62
  if (this.attrValue?.length > 1)
43
- return super.apply([expr]);
63
+ return super.applyAll([expr]);
44
64
 
45
65
  // Don't bind events to component placeholders.
46
66
  // PathToComponent will do the binding later when it instantiates the component.
47
67
  if (this.isComponentAttrib && this.nodeMarker.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
48
68
  return;
49
69
 
50
- let root = this.parentNg.rootNg.root
70
+ let root = this.parentNg.rootNg.rootEl
51
71
 
52
- /*#IFDEV*/
72
+ /*#IFDEBUG*/
53
73
  assert(root?.nodeType === 1);
54
74
  /*#ENDIF*/
55
75
 
@@ -67,7 +87,7 @@ export default class PathToEvent extends PathToAttribValue {
67
87
  expr = null;
68
88
  }
69
89
  else
70
- throw new Error(`Invalid event binding: <${node.tagName.toLowerCase()} ${this.attrName}=\${${JSON.stringify(expr)}}>`);
90
+ throw new Error(`Solarite: ${this.attribName}=\${...} is not a function.`);
71
91
 
72
92
  this.bindEvent(node, root, eventName, eventName, func, expr);
73
93
  }
package/src/PathToKey.js CHANGED
@@ -7,12 +7,6 @@ import Path from "./Path.js";
7
7
  * matches NodeGroups to new templates by this key. */
8
8
  export default class PathToKey extends Path {
9
9
 
10
- /**
11
- * @param exprs {Expr[]} Only the first is used. */
12
- apply(exprs) {
13
- this.parentNg.key = exprs[0];
14
- }
15
-
16
10
  applySingle(expr) {
17
11
  this.parentNg.key = expr;
18
12
  }