solarite 0.8.0 → 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.
@@ -78,106 +78,120 @@ export default class PathToComponent extends Path {
78
78
  }
79
79
  }
80
80
 
81
- // 2. Instantiate component on first time.
82
- let isAttrib = el.getAttribute('_is');
83
- if (el.tagName.endsWith('-SOLARITE-PLACEHOLDER') || isAttrib) {
84
-
85
-
86
- // 2a. Instantiate component
87
- let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
88
- let Constructor = customElements.get(tagName);
89
-
90
- // Not defined yet (e.g. the module is being lazily imported): keep the placeholder
91
- // and instantiate when the definition lands, like a native custom-element upgrade.
92
- // deferredExprs always holds the LATEST exprs so re-renders while undefined win.
93
- if (!Constructor) {
94
- this.deferredExprs = exprs;
95
- if (!this.whenDefinedPending) {
96
- this.whenDefinedPending = true;
97
- console.warn(`Solarite: <${tagName}> is not defined yet; waiting for customElements.define().`);
98
- customElements.whenDefined(tagName).then(() => {
99
- this.whenDefinedPending = false;
100
- let deferred = this.deferredExprs;
101
- this.deferredExprs = null;
102
- // Skip if a newer render already instantiated or replaced the placeholder.
103
- if (deferred && this.nodeMarker === el && el.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
104
- this.applyAll(deferred);
105
- });
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;
106
115
  }
107
- Globals.currentSlotChildren = null;
108
- return;
109
- }
110
-
111
- Globals.currentSlotChildren = [...el.childNodes]; // TODO: Does this need to be a stack?
112
- let newEl = new Constructor(attribs);
113
116
 
114
- // 2b. Copy attributes over.
115
- if (isAttrib) {
116
- newEl.setAttribute('is', isAttrib);
117
- // el.removeAttribute('_is');
118
- }
119
- for (let attrib of el.attributes)
120
- if (attrib.name !== '_is')
121
- newEl.setAttribute(attrib.name, attrib.value);
122
-
123
- // Set dynamic attributes if they are primitive types.
124
- for (let name in attribs) {
125
- let val = attribs[name];
126
- let valType = typeof val;
127
- // Only true and false can reach here, so the undefined/null halves of the
128
- // falsy test this used to spell out could never have decided anything.
129
- if (valType === 'boolean') {
130
- if (val)
131
- newEl.setAttribute(name, '');
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);
132
148
  }
133
149
 
134
- // If type is a non-boolean primitive, set the attribute value.
135
- else if (valType==='string' || valType === 'number' || valType==='bigint')
136
- newEl.setAttribute(name, val);
137
- }
138
150
 
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);
139
155
 
140
- // 2c. If an id pointed at the placeholder, update it to point to the new element.
141
- let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
142
- if (id)
143
- delve(this.parentNg.getRootEl(), id.split(/\./g), newEl);
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]);
181
+ }
144
182
 
145
- // 2d. Update paths to use replaced element.
146
- let ng = this.parentNg;
147
- this.nodeMarker = newEl;
148
- for (let path of ng.paths) {
149
- if (path.nodeMarker === el)
150
- path.nodeMarker = newEl;
151
- if (path.nodeBefore === el)
152
- path.nodeBefore = newEl;
153
- }
154
- if (ng.startNode === el)
155
- ng.startNode = newEl;
156
- if (ng.endNode === el)
157
- ng.endNode = newEl;
158
-
159
- // 2f. Call render() if it wasn't called by the constructor.
160
- // This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
161
- // Because that path renders it without the attribute expressions.
162
- if (typeof newEl.render === 'function' && !Globals.rendered.has(newEl))
163
- newEl.render(attribs, true);
164
-
165
- // 2g. Update attribute paths to use the new element and re-apply them.
166
- for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
167
- attribPath.parentNg = this.parentNg;
168
- attribPath.nodeMarker = newEl;
169
- attribPath.applyAll(exprs[i]);
183
+ // 2e. Swap it to the DOM.
184
+ el.replaceWith(newEl);
170
185
  }
171
186
 
172
- // 2e. Swap it to the DOM.
173
- el.replaceWith(newEl);
174
- }
175
-
176
- // 2f. Render
177
- else if (typeof el.render === 'function')
178
- el.render(attribs, changed);
187
+ // 2f. Render
188
+ else if (typeof el.render === 'function')
189
+ el.render(attribs, changed);
179
190
 
180
- Globals.currentSlotChildren = null;
191
+ }
192
+ finally {
193
+ Globals.currentSlotChildren = prevSlotChildren;
194
+ }
181
195
  }
182
196
 
183
197
  /**
@@ -1,5 +1,6 @@
1
1
  import assert from "./assert.js";
2
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 {
@@ -11,11 +12,24 @@ export default class PathToEvent extends PathToAttribValue {
11
12
  * Undefined for non-delegatable (non-bubbling) events; bindEvent() then binds directly. */
12
13
  delegatedKey;
13
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
+
14
20
  constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
15
21
  super(null, nodeMarker, attribName, attrValue);
16
22
  this.skipIfSame = true;
17
- this.eventName = attribName ? attribName.slice(2) : null;
18
- this.delegatedKey = this.eventName !== null ? delegatedKeyFor(this.eventName) : undefined;
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;
19
33
  }
20
34
 
21
35
  /**
@@ -30,13 +30,30 @@ export default class RootNodeGroup extends NodeGroup {
30
30
  if (el) {
31
31
  this.rootEl = el;
32
32
 
33
- // Save slot
34
- // 1. Globals.currentSlotChildren is set if this is called via PathToComponent.applyComponent() calls render()
35
- // 2. el.childNodes is set if render() is called manually for the first time.
33
+ // Save the children that belong in this component's <slot>, from one of two places:
34
+ // 1. A hand-off parked by PathToComponent.applyAll() just before it constructed
35
+ // us, when this component was declared inside another template. It carries
36
+ // the Constructor it was meant for, so an unrelated component built in the
37
+ // meantime -- a field initializer creating a menu, say -- leaves it alone.
38
+ // 2. el.childNodes, when render() is called manually for the first time.
39
+ // An addressed hand-off wins even when its node list is empty: a component
40
+ // declared as <my-tag></my-tag> is asking for an empty slot, not for whatever
41
+ // its own constructor happened to put in the element.
42
+ //
43
+ // The hand-off is deliberately NOT cleared on read. A component that builds
44
+ // another instance of its OWN class while constructing cannot be told apart
45
+ // from itself by any address, so both match; the inner one takes the nodes and
46
+ // this outer one takes them straight back, which is the only thing that makes
47
+ // that case work.
48
+ let handOff = Globals.currentSlotChildren;
49
+ let mySlotNodes = handOff?.Constructor === el.constructor
50
+ ? handOff.nodes
51
+ : (el.childNodes.length ? [...el.childNodes] : null);
52
+
36
53
  let slotChildren;
37
- if (Globals.currentSlotChildren || el.childNodes.length) {
54
+ if (mySlotNodes) {
38
55
  slotChildren = Globals.doc.createDocumentFragment();
39
- slotChildren.append(...(Globals.currentSlotChildren || el.childNodes));
56
+ slotChildren.append(...mySlotNodes);
40
57
  }
41
58
 
42
59
  // If el should replace the root node of the fragment.
package/src/Shell.js CHANGED
@@ -168,6 +168,9 @@ export default class Shell {
168
168
  // Smaller fragments make cloning, path resolution, and insertion faster.
169
169
  stripTableWhitespace(this.docFrag);
170
170
 
171
+ // 1c. Neutralize `is` so the browser can't upgrade a placeholder out from under us.
172
+ renameIsAttribs(this.docFrag);
173
+
171
174
  // 2. Find placeholders
172
175
  let node;
173
176
  let toRemove = [];
@@ -181,7 +184,7 @@ export default class Shell {
181
184
 
182
185
  // Replace attributes
183
186
  if (node.nodeType === 1) {
184
- const hasIs = node.hasAttribute('is');
187
+ const hasIs = node.hasAttribute('_is'); // Renamed from `is` in step 1c.
185
188
  const isComponent = (hasIs || node.tagName.includes('-'));
186
189
  const componentAttribPaths = [];
187
190
 
@@ -285,10 +288,6 @@ export default class Shell {
285
288
  path.attribPaths = componentAttribPaths;
286
289
  this.paths.splice(this.paths.length - componentAttribPaths.length, 0, path); // Insert before its componentAttribPaths
287
290
 
288
- if (hasIs) {
289
- node.setAttribute('_is', node.getAttribute('is'));
290
- node.removeAttribute('is');
291
- }
292
291
  }
293
292
  }
294
293
 
@@ -305,7 +304,7 @@ export default class Shell {
305
304
  // Components and slots are excluded because they move their children
306
305
  // during instantiation, which would orphan the expression's region.
307
306
  if (parent.nodeType === 1 && !node.previousSibling && !node.nextSibling
308
- && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('is')) {
307
+ && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('_is')) {
309
308
  let path = new PathToNodes(null, parent);
310
309
  path.wholeParent = true;
311
310
  this.paths.push(path);
@@ -719,6 +718,38 @@ function stripTableWhitespace(el) {
719
718
  }
720
719
  }
721
720
 
721
+ /**
722
+ * Rename every `is` attribute to `_is`, rebuilding the element to do it.
723
+ *
724
+ * A component written as a dashed tag is neutralized in the shell by renaming the TAG
725
+ * (`<my-tag>` becomes `<my-tag-SOLARITE-PLACEHOLDER>`), so the browser never recognizes the
726
+ * placeholder and never upgrades it. A customized built-in cannot be neutralized that way,
727
+ * because its tag has to stay real: a `<tr is="my-row">` that is not a `<tr>` is thrown out
728
+ * by the parser's table rules. So its ATTRIBUTE is renamed instead.
729
+ *
730
+ * Renaming the attribute in place is not enough. `is` is also recorded in an internal slot on
731
+ * the element, which removeAttribute() cannot clear and cloneNode() copies, so a placeholder
732
+ * that was parsed with `is` stays a customized built-in as far as the browser is concerned.
733
+ * Every clone of it is upgraded the moment it enters a document with a browsing context —
734
+ * running the component's constructor on the placeholder, before PathToComponent has
735
+ * instantiated the real element or evaluated the attribute expressions meant for it. A
736
+ * constructor that renders then renders the placeholder, whose children are the ones the user
737
+ * declared, and those get handed to the real instance as if they were slot content.
738
+ *
739
+ * Building a fresh element and moving everything across is the only way to drop that slot.
740
+ * It happens once per unique template, because Shells are cached, and never per render.
741
+ *
742
+ * @param docFrag {DocumentFragment} */
743
+ function renameIsAttribs(docFrag) {
744
+ for (let el of docFrag.querySelectorAll('[is]')) {
745
+ let clean = el.ownerDocument.createElement(el.tagName);
746
+ for (let attrib of el.attributes)
747
+ clean.setAttribute(attrib.name === 'is' ? '_is' : attrib.name, attrib.value);
748
+ clean.append(...el.childNodes);
749
+ el.replaceWith(clean);
750
+ }
751
+ }
752
+
722
753
  // One-entry memo for Shell.get().
723
754
  let lastHtmlStrings = null, lastSvgMode = false, lastShell = null;
724
755
 
package/src/Solarite.d.ts CHANGED
@@ -10,17 +10,19 @@ export interface RenderOptions {
10
10
  ids?: boolean;
11
11
  render?: boolean;
12
12
 
13
- /** Defaults to true: bubbling events (click, input, etc.) dispatch from one listener on
14
- * the component's root element instead of addEventListener per element - much faster
15
- * creation and teardown of large lists. Pass false to bind every event directly, or an
16
- * array to delegate only the listed event names. Pass 'document' to also register the
17
- * dispatcher on the document, so handlers keep firing on nodes that get re-parented
18
- * outside the component (e.g. a toolbar a dock parks in its own chrome). Non-bubbling
19
- * events always bind directly. Note: delegated handlers run when the event bubbles to
20
- * the root (or document), so stopPropagation() in a manually added listener on an element
21
- * in between suppresses them, and such manual listeners fire first. A programmatically
22
- * dispatched non-bubbling event won't reach delegated handlers. */
23
- eventDelegation?: boolean | string[] | 'document';
13
+ /**
14
+ * Delegate bubbling events (default true). A handler is stored on its element instead of
15
+ * registered with addEventListener, and when an event of that type starts, a capture-phase
16
+ * listener on the component root and the document attaches a real listener to each element
17
+ * on the event's path that has one; the browser then dispatches normally. Much faster
18
+ * creation and teardown of large lists, with native ordering, stopPropagation(),
19
+ * currentTarget, non-bubbling events and moved elements all behaving as with
20
+ * addEventListener. The one difference: a delegated handler is attached when the event
21
+ * starts, so it runs after listeners other code added to the same element. Prefix an
22
+ * attribute with native: (native:onclick) to bind just that handler at render time.
23
+ * Pass false to bind every event directly, or an array to delegate only the listed event
24
+ * names. Non-bubbling events always bind directly. */
25
+ eventDelegation?: boolean | string[];
24
26
  }
25
27
 
26
28
  /**
@@ -183,7 +185,7 @@ export function delve(obj: object, path: string[], createVal?: any): any;
183
185
  * Internal utilities and state. */
184
186
  export const Globals: {
185
187
  connected: WeakSet<HTMLElement>;
186
- currentSlotChildren: any[] | null;
188
+ currentSlotChildren: {Constructor: Function, nodes: Node[]} | null;
187
189
  div: HTMLDivElement;
188
190
  doc: Document;
189
191
  elementClasses: {[key: string]: typeof Node};
package/src/Util.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import Globals from "./Globals.js";
2
2
  import delve from "./delve.js";
3
3
 
4
+ /**
5
+ * Prefix that asks for a handler to bypass event delegation: `<button native:onclick=\${...}>`
6
+ * is bound with addEventListener at render time, taking its normal place in the browser's own
7
+ * dispatch order. Shared by Util.isEvent() and PathToEvent, which strips it. */
8
+ export const nativeEventPrefix = 'native:';
9
+
4
10
  let Util = {
5
11
 
6
12
  /**
@@ -200,7 +206,14 @@ let Util = {
200
206
  return node.value; // String
201
207
  },
202
208
 
209
+ /**
210
+ * True for an attribute name that binds an event: `onclick`, or `native:onclick` for a
211
+ * handler that is registered with addEventListener when the template renders instead of
212
+ * being delegated. Only names an element really exposes as on* handlers count, so an
213
+ * attribute like `online` is never mistaken for one. */
203
214
  isEvent(attribName) {
215
+ if (attribName.startsWith(nativeEventPrefix))
216
+ attribName = attribName.slice(nativeEventPrefix.length);
204
217
  return attribName.startsWith('on') && attribName in Globals.div;
205
218
  },
206
219