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.
package/src/Shell.js CHANGED
@@ -2,7 +2,6 @@ import assert from "./assert.js";
2
2
  import Path from "./Path.js";
3
3
  import Util from "./Util.js";
4
4
  import Globals from "./Globals.js";
5
- import HtmlParser from "./HtmlParser.js";
6
5
  import PathToEvent from "./PathToEvent.js";
7
6
  import PathToAttribValue from "./PathToAttribValue.js";
8
7
  import PathToAttribs from "./PathToAttribs.js";
@@ -21,7 +20,7 @@ export default class Shell {
21
20
 
22
21
  /**
23
22
  * @type {DocumentFragment|Text} DOM parent of the shell's nodes. */
24
- fragment;
23
+ docFrag;
25
24
 
26
25
  /** @type {Path[]} Paths to where expressions should go. */
27
26
  paths = [];
@@ -41,10 +40,22 @@ export default class Shell {
41
40
  /** @type {boolean} True if any of this Shell's own paths is a PathToComponent. */
42
41
  hasComponentPaths = false;
43
42
 
43
+ /** @type {boolean} True if any path binds an attribute that's a live HTML property
44
+ * (checked, value, selected — Util.isHtmlProp). Users flip those underneath the template,
45
+ * so "expression unchanged" doesn't mean "DOM unchanged" and the skip shortcuts exempt them. */
46
+ hasLivePropPaths = false;
47
+
44
48
  /** @type {boolean} True if every path consumes exactly one expression and none are components.
45
49
  * Lets NodeGroup.applyExprs() use a fast loop without allocating per-path expression arrays. */
46
50
  pathsSingleExpr = false;
47
51
 
52
+ /** @type {boolean} True when a NodeGroup whose values are unchanged still has work to do:
53
+ * components re-render so changes deeper in the tree surface, and live HTML properties are
54
+ * rewritten because a click can flip them underneath the cached expression. The list scans
55
+ * check this before calling PathToNodes.refreshSameItem(), so the overwhelmingly common
56
+ * unchanged row costs one field read instead of a call. */
57
+ needsRefresh = false;
58
+
48
59
  /** @type {boolean} True if this Shell has any ids, styles, or scripts. */
49
60
  hasEmbeds = false;
50
61
 
@@ -60,6 +71,52 @@ export default class Shell {
60
71
  * with no per-instance Path objects. See the stampPaths setup in the constructor. */
61
72
  stampable = false;
62
73
 
74
+ // The remaining fields are only filled in for some shells (resolve program, stampable),
75
+ // but they're all declared here so every Shell instance shares one hidden class.
76
+ // NodeGroup's per-row code (its constructor, applyStamp, resolveStampSlots) reads these
77
+ // off whichever shell it's given, and a single shape keeps those loads monomorphic.
78
+
79
+ /** @type {?string} The Template close key, cached here by the NodeGroup constructor so
80
+ * each new template row skips a WeakMap lookup. See Template.getCloseKey(). */
81
+ closeKey;
82
+
83
+ /** @type {?int[]} The resolve program: flat [parentSlot, childIndex] pairs in dependency
84
+ * order; pair i fills slot i+1, slot 0 being the fragment. Built by buildResolveProgram();
85
+ * undefined for shells with components. */
86
+ resolveOps;
87
+
88
+ /** @type {?Node[]} Reusable scratch array for resolved nodes; safe because resolution
89
+ * never re-enters. */
90
+ resolveSlots;
91
+
92
+ // The stamp program, set only when stampable is true:
93
+
94
+ /** @type {?int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
95
+ nodesPathIdx;
96
+
97
+ /** @type {?Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
98
+ stampPaths;
99
+
100
+ /** @type {?Uint8Array} Opcode per path; see the stamp-program comment in the constructor. */
101
+ stampOp;
102
+
103
+ /** @type {?Uint16Array} paths[i].markerSlot, in a flat array so the hot loop
104
+ * doesn't load the Path object to find its slot. */
105
+ stampSlot;
106
+
107
+ /** @type {?Path[]} Per-path extra the stamp program needs: the event stamper for op 3
108
+ * (it carries delegatedKey and eventName), the attribute name for op 4, null otherwise. */
109
+ stampAux;
110
+
111
+ /** @type {?string[]} The delegatable event names this shell binds, so a loop can register
112
+ * their dispatchers once for the whole run of rows instead of testing every bound node. */
113
+ stampEventNames;
114
+
115
+ /** @type {?Uint8Array} Per-path flags the in-place rewrite loop needs, so it reads one byte
116
+ * from a flat array instead of two properties from a Path object it otherwise wouldn't
117
+ * touch. Bit 1 = the path binds a live HTML property, bit 2 = it's a whole-parent child. */
118
+ stampFlags;
119
+
63
120
  /**
64
121
  * Create the nodes but without filling in the expressions.
65
122
  * This is useful because the expression-less nodes created by a template can be cached.
@@ -69,13 +126,13 @@ export default class Shell {
69
126
  if (!html)
70
127
  return;
71
128
 
72
- //#IFDEV
129
+ //#IFDEBUG
73
130
  this._html = html.join('');
74
131
  //#ENDIF
75
132
 
76
133
  // If no html tags or entities, just create a text node.
77
134
  if (html.length === 1 && !html[0].match(/[<&]/)) {
78
- this.fragment = Globals.doc.createTextNode(html[0]);
135
+ this.docFrag = Globals.doc.createTextNode(html[0]);
79
136
  return;
80
137
  }
81
138
 
@@ -93,29 +150,32 @@ export default class Shell {
93
150
  let frag = Globals.doc.createDocumentFragment();
94
151
  while (svgEl.firstChild)
95
152
  frag.append(svgEl.firstChild);
96
- this.fragment = frag;
153
+ this.docFrag = frag;
97
154
  }
98
155
  else {
99
156
  template.innerHTML = htmlWithPlaceholders;
100
- this.fragment = template.content;
157
+ this.docFrag = template.content;
101
158
  }
102
159
  }
103
160
  else { // Create one text node, so shell isn't empty and NodeGroups created from it have something to point the startNode and endNode at.
104
161
  template.content.append(Globals.doc.createTextNode(''))
105
- this.fragment = template.content;
162
+ this.docFrag = template.content;
106
163
  }
107
164
 
108
165
  // 1b. Remove whitespace-only text nodes inside table-structure elements.
109
166
  // The parser foster-parents non-whitespace text out of tables, and whitespace-only
110
167
  // text between cells/rows is never rendered, so removing it is invisible.
111
168
  // Smaller fragments make cloning, path resolution, and insertion faster.
112
- stripTableWhitespace(this.fragment);
169
+ stripTableWhitespace(this.docFrag);
170
+
171
+ // 1c. Neutralize `is` so the browser can't upgrade a placeholder out from under us.
172
+ renameIsAttribs(this.docFrag);
113
173
 
114
174
  // 2. Find placeholders
115
175
  let node;
116
176
  let toRemove = [];
117
177
  let placeholdersUsed = 0;
118
- const walker = Globals.doc.createTreeWalker(this.fragment, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
178
+ const walker = Globals.doc.createTreeWalker(this.docFrag, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
119
179
  while (node = walker.nextNode()) {
120
180
 
121
181
  // Remove previous elements after each iteration, so paths will still be calculated correctly.
@@ -124,7 +184,7 @@ export default class Shell {
124
184
 
125
185
  // Replace attributes
126
186
  if (node.nodeType === 1) {
127
- const hasIs = node.hasAttribute('is');
187
+ const hasIs = node.hasAttribute('_is'); // Renamed from `is` in step 1c.
128
188
  const isComponent = (hasIs || node.tagName.includes('-'));
129
189
  const componentAttribPaths = [];
130
190
 
@@ -133,13 +193,20 @@ export default class Shell {
133
193
  // The reserved key attribute identifies this template within a keyed list.
134
194
  // It's consumed here and never written to the DOM or passed to components.
135
195
  if (attr.name === 'key') {
196
+
197
+ // These three are template-authoring mistakes, and every one of them fails SILENTLY if
198
+ // it isn't caught: the reconciler would key rows on a garbage value and reuse the wrong
199
+ // DOM, with nothing reported. So they ship, unlike the assertions elsewhere in this
200
+ // file. The cost is one regex split per unique template \u2014 never per render, never per
201
+ // row \u2014 which is why they are affordable to keep.
136
202
  let parts = attr.value.split(/[\ue000-\uf8ff]/g);
137
203
  if (parts.length !== 2 || parts[0] !== '' || parts[1] !== '')
138
- throw new Error(`The key attribute is reserved and must be a single expression: key=\${...}`);
139
- if (node.parentNode !== this.fragment)
140
- throw new Error(`The key attribute must be on a top-level element of its template.`);
204
+ throw new Error(`Solarite: key must be one whole expression.`);
205
+ if (node.parentNode !== this.docFrag)
206
+ throw new Error(`Solarite: key must be on a top-level element.`);
141
207
  if (this.keyIndex >= 0)
142
- throw new Error(`A template can have only one key attribute.`);
208
+ throw new Error(`Solarite: duplicate key attribute.`);
209
+
143
210
  this.keyIndex = attr.value.charCodeAt(0) - attribPlaceholder;
144
211
 
145
212
  let path = new PathToKey(null, node);
@@ -184,19 +251,33 @@ export default class Shell {
184
251
  }
185
252
 
186
253
  placeholdersUsed += parts.length - 1;
187
- // In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the placeholders
188
- // stripped out makes the browser log parse errors, both here and when the fragment is cloned.
189
- // Remove the attribute instead; apply() recreates it with the real values.
190
- // Event attributes bound to a single expression are removed because they bind via
191
- // addEventListener; leaving an empty onclick="" attribute violates a strict CSP when the event fires.
192
- if (svgMode || (isEvent && !nonEmptyParts))
254
+ // An attribute whose whole value is one expression is removed from the shell:
255
+ // its stamped value is always the empty string, so every clone would carry a
256
+ // useless empty attribute that costs storage on creation and a slot in the
257
+ // element's attribute list forever, and apply() writes the real value anyway
258
+ // (a missing attribute reads back as '', so an empty expression still writes
259
+ // nothing). Event attributes must be removed for the same reason plus a
260
+ // stricter one: an empty onclick="" violates a strict CSP when the event fires.
261
+ // In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the
262
+ // placeholders stripped out makes the browser log parse errors, both here and
263
+ // when the fragment is cloned, so those are removed whether or not they're whole.
264
+ if (svgMode || !nonEmptyParts)
193
265
  node.removeAttribute(attr.name);
194
- else try {
266
+
267
+ // setAttribute throws only when the template author wrote a name the browser
268
+ // refuses, such as one holding a space or a quote. That name comes from a tagged
269
+ // template literal's static text, so it is a typo that surfaces the first time the
270
+ // template renders and can never appear later or for only some users. Development
271
+ // therefore wraps the call to rethrow with the attribute name and the tag included,
272
+ // because the browser's own DOMException names neither and leaves the author
273
+ // hunting. Production ships the bare call and lets that DOMException through: the
274
+ // friendlier wording is only worth its bytes to whoever can still fix the template.
275
+ else /*#IFDEBUG*/try {/*#ENDIF*/
195
276
  node.setAttribute(attr.name, parts.join(''));
196
- }
277
+ /*#IFDEBUG*/}
197
278
  catch (e) {
198
279
  throw new Error(`Error setting attribute "${attr.name}" on node <${node.tagName}>: ${e.message}`);
199
- }
280
+ }/*#ENDIF*/
200
281
  }
201
282
  }
202
283
  }
@@ -207,10 +288,6 @@ export default class Shell {
207
288
  path.attribPaths = componentAttribPaths;
208
289
  this.paths.splice(this.paths.length - componentAttribPaths.length, 0, path); // Insert before its componentAttribPaths
209
290
 
210
- if (hasIs) {
211
- node.setAttribute('_is', node.getAttribute('is'));
212
- node.removeAttribute('is');
213
- }
214
291
  }
215
292
  }
216
293
 
@@ -218,7 +295,7 @@ export default class Shell {
218
295
  else if (node.nodeType === 8 && node.nodeValue === '!✨!') {
219
296
 
220
297
  if (node?.parentNode?.closest && node?.parentNode?.closest('[contenteditable]'))
221
- throw new Error(`Contenteditable can't have expressions inside them. Use <div contenteditable value="\${...}"> instead.`);
298
+ throw new Error(`Solarite: no \${...} inside contenteditable; use value="\${...}".`);
222
299
 
223
300
  let parent = node.parentNode;
224
301
 
@@ -227,7 +304,7 @@ export default class Shell {
227
304
  // Components and slots are excluded because they move their children
228
305
  // during instantiation, which would orphan the expression's region.
229
306
  if (parent.nodeType === 1 && !node.previousSibling && !node.nextSibling
230
- && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('is')) {
307
+ && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('_is')) {
231
308
  let path = new PathToNodes(null, parent);
232
309
  path.wholeParent = true;
233
310
  this.paths.push(path);
@@ -242,7 +319,7 @@ export default class Shell {
242
319
  nodeBefore = Globals.doc.createComment('Path:'+this.paths.length);
243
320
  node.parentNode.insertBefore(nodeBefore, node)
244
321
  }
245
- /*#IFDEV*/assert(nodeBefore);/*#ENDIF*/
322
+ /*#IFDEBUG*/assert(nodeBefore);/*#ENDIF*/
246
323
 
247
324
  // Get the next node.
248
325
  let nodeMarker;
@@ -257,7 +334,7 @@ export default class Shell {
257
334
  nodeMarker = node;
258
335
  nodeMarker.textContent = 'PathEnd:'+ this.paths.length;
259
336
  }
260
- /*#IFDEV*/assert(nodeMarker);/*#ENDIF*/
337
+ /*#IFDEBUG*/assert(nodeMarker);/*#ENDIF*/
261
338
 
262
339
  let path = new PathToNodes(nodeBefore, nodeMarker);
263
340
  this.paths.push(path);
@@ -265,11 +342,6 @@ export default class Shell {
265
342
  }
266
343
  }
267
344
 
268
- // Comments become text nodes when inside textareas.
269
- else if (node.nodeType === 3 && node.parentNode?.tagName === 'TEXTAREA' && node.textContent.includes('<!--!✨!-->'))
270
- throw new Error(`Textarea can't have expressions inside them. Use <textarea value="\${...}"> instead.`);
271
-
272
-
273
345
  // Sometimes users will comment out a block of html code that has expressions.
274
346
  // Here we look for expressions in comments.
275
347
  // We don't actually update them dynamically, but we still add paths for them.
@@ -283,29 +355,39 @@ export default class Shell {
283
355
  }
284
356
  }
285
357
 
286
- // Replace comment placeholders inside script and style tags, which have become text nodes.
287
- else if (node.nodeType === 3 && ['SCRIPT', 'STYLE'].includes(node.parentNode?.nodeName)) { // Node.TEXT_NODE
288
- let parts = node.textContent.split(commentPlaceholder);
289
- if (parts.length > 1) {
290
-
291
- let placeholders = [];
292
- for (let i = 0; i<parts.length; i++) {
293
- let current = Globals.doc.createTextNode(parts[i]);
294
- node.parentNode.insertBefore(current, node);
295
- if (i > 0)
296
- placeholders.push(current)
297
- }
298
-
299
- for (let i=0, node; node=placeholders[i]; i++) {
300
- let path = new PathToNodes(node.previousSibling, node);
301
- this.paths.push(path);
302
- placeholdersUsed ++;
358
+ // A few elements have raw-text bodies, which the html parser reads as literal characters
359
+ // rather than as markup. A comment placeholder written inside one therefore never becomes
360
+ // a comment node; it arrives here as ordinary text. A textarea can't support expressions
361
+ // in its body at all, while script and style can, by splitting their text around each
362
+ // placeholder so that every expression gets a text node of its own to write into.
363
+ else if (node.nodeType === 3) { // Node.TEXT_NODE
364
+ let parentName = node.parentNode?.nodeName;
365
+
366
+ if (parentName === 'TEXTAREA' && node.textContent.includes(commentPlaceholder))
367
+ throw new Error(`Solarite: no \${...} inside textarea; use value="\${...}".`);
368
+
369
+ else if (parentName === 'SCRIPT' || parentName === 'STYLE') {
370
+ let parts = node.textContent.split(commentPlaceholder);
371
+ if (parts.length > 1) {
372
+
373
+ // Every part is inserted before the original node, in order, so from the second
374
+ // part onward the text node made on the previous iteration is already sitting
375
+ // immediately before this one and serves as the new path's nodeBefore.
376
+ for (let i = 0; i<parts.length; i++) {
377
+ let current = Globals.doc.createTextNode(parts[i]);
378
+ node.parentNode.insertBefore(current, node);
379
+ if (i > 0) {
380
+ let path = new PathToNodes(current.previousSibling, current);
381
+ this.paths.push(path);
382
+ placeholdersUsed ++;
383
+
384
+ /*#IFDEBUG*/path.verify();/*#ENDIF*/
385
+ }
386
+ }
303
387
 
304
- /*#IFDEV*/path.verify();/*#ENDIF*/
388
+ // Removing it here will mess up the treeWalker.
389
+ toRemove.push(node);
305
390
  }
306
-
307
- // Removing them here will mess up the treeWalker.
308
- toRemove.push(node);
309
391
  }
310
392
  }
311
393
  }
@@ -314,31 +396,37 @@ export default class Shell {
314
396
  // Less than or equal because there can be one path to multiple expressions
315
397
  // if those expressions are in the same attribute value.
316
398
  if (placeholdersUsed !== html.length-1)
317
- throw new Error(`Could not parse expressions in template. Check for duplicate attributes or malformed html: ${html.join('${...}')}`);
399
+ throw new Error(`Solarite: bad html or duplicate attribute: ${html.join('${...}')}`);
318
400
 
319
401
  for (let path of this.paths) {
320
- if (path.nodeBefore)
321
- path.nodeBeforeIndex = Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
402
+ // -1 when the path has no nodeBefore. Assigned unconditionally so every shell path
403
+ // of a given class takes the same property-addition order and shares one hidden class.
404
+ path.nodeBeforeIndex = path.nodeBefore
405
+ ? Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
406
+ : -1;
322
407
 
323
408
  // Must be calculated after we remove the toRemove nodes:
324
409
  path.nodeMarkerPath = Path.get(path.nodeMarker)
325
-
326
-
327
410
  }
328
411
 
329
412
  this.findEmbeds();
330
- this.buildResolveProgram();
331
413
 
414
+ // This scan must run before buildResolveProgram(), which skips shells with components
415
+ // and reads hasComponentPaths rather than walking the paths a second time.
332
416
  this.pathsSingleExpr = true;
333
417
  for (let path of this.paths) {
334
418
  if (path instanceof PathToComponent) {
335
419
  this.hasComponentPaths = true;
336
420
  this.pathsSingleExpr = false;
337
- break; // Both facts are now decided.
338
421
  }
339
- if (path.getExpressionCount() !== 1)
340
- this.pathsSingleExpr = false; // Keep scanning for components.
422
+ else if (path.getExpressionCount() !== 1)
423
+ this.pathsSingleExpr = false;
424
+ if (path.isHtmlProperty) // needs the full scan — no early break
425
+ this.hasLivePropPaths = true;
341
426
  }
427
+ this.needsRefresh = this.hasComponentPaths || (this.hasLivePropPaths && this.pathsSingleExpr);
428
+
429
+ this.buildResolveProgram();
342
430
 
343
431
  // Stampable shells create NodeGroups without allocating any Path objects:
344
432
  // NodeGroup.applyStamp() writes expressions through these shared stamper paths,
@@ -363,17 +451,50 @@ export default class Shell {
363
451
  }
364
452
  if (ok) {
365
453
  this.stampable = true;
366
-
367
- /** @type {int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
368
454
  this.nodesPathIdx = nodesIdx;
369
-
370
- /** @type {Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
371
455
  this.stampPaths = this.paths.map(p => p.cloneWithNodes(null, p.nodeMarker));
372
456
 
457
+ // Compiled stamp program: one opcode per path lets applyStamp() write a fresh
458
+ // row through a flat branch chain instead of dispatching applySingle() per path.
459
+ // 0 = generic (shared stamper fallback), 1 = list key (no DOM), 2 = wholeParent
460
+ // child text, 3 = delegatable single-expression event (written as node expandos
461
+ // when the root delegates, the default).
462
+ let n = this.paths.length;
463
+ this.stampOp = new Uint8Array(n);
464
+ this.stampSlot = new Uint16Array(n);
465
+ this.stampAux = new Array(n).fill(null);
466
+ this.stampFlags = new Uint8Array(n);
467
+
468
+ let eventNames = null;
469
+ for (let i=0; i<n; i++) {
470
+ let p = this.paths[i], sp = this.stampPaths[i];
471
+ this.stampSlot[i] = p.markerSlot;
472
+ this.stampFlags[i] = (sp.isHtmlProperty ? 1 : 0) | (sp.wholeParent ? 2 : 0);
473
+ if (p instanceof PathToKey)
474
+ this.stampOp[i] = 1;
475
+ else if (sp.wholeParent)
476
+ this.stampOp[i] = 2;
477
+ else if (sp instanceof PathToEvent && sp.delegatedKey !== undefined && !sp.attrValue) {
478
+ this.stampOp[i] = 3;
479
+ this.stampAux[i] = sp;
480
+ (eventNames ??= []).push(sp.eventName);
481
+ }
482
+
483
+ // A plain attribute holding one whole expression. The shell no longer carries
484
+ // the attribute at all (see the placeholder handling above), so on a freshly
485
+ // cloned row the value is known to be absent and a string can be written
486
+ // without first reading back what's there.
487
+ else if (sp instanceof PathToAttribValue && !sp.attrValue && !sp.isHtmlProperty
488
+ && !sp.isComponentAttrib) {
489
+ this.stampOp[i] = 4;
490
+ this.stampAux[i] = sp.attribName;
491
+ }
492
+ }
493
+ this.stampEventNames = eventNames;
373
494
  }
374
495
  }
375
496
 
376
- /*#IFDEV*/this.verify();/*#ENDIF*/
497
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
377
498
  }
378
499
 
379
500
  /**
@@ -384,42 +505,64 @@ export default class Shell {
384
505
  * @param htmlChunks {string[]}
385
506
  * @returns {string} Html with the placeholders in place. */
386
507
  static addPlaceholders(htmlChunks) {
387
- let result = [];
508
+ let result = '';
509
+
510
+ // Where the tokenizer is as it walks the chunks. An expression can sit in the middle of an attribute
511
+ // value, so both of these have to survive from one chunk to the next. Nothing else has to: an
512
+ // expression anywhere inside a tag gets the same attribute placeholder, so the machine only has to
513
+ // know whether it is inside a tag at all, and whether a quoted value is currently open.
514
+ let inTag = false; // True from the '<' that opens a tag or comment through the '>' that closes it.
515
+ let quote = null; // The quote character that opened the attribute value we're inside of: null, '"', or "'".
388
516
 
389
- let htmlParser = new HtmlParser(); // Reset the context.
390
517
  for (let i = 0; i < htmlChunks.length; i++) {
391
- let lastHtml = htmlChunks[i];
518
+ let html = htmlChunks[i];
392
519
 
393
520
  // Append -solarite-placholder to web component tags, so we can pass args to them when they're instantiated.
394
- let lastIndex = 0;
395
- let context = htmlParser.parse(lastHtml, (html, index, prevContext/*, nextContext*/) => { // This function is called every time the html context changes.
396
- if (lastIndex !== index) {
397
- let token = html.slice(lastIndex, index);
398
-
399
- if (prevContext === HtmlParser.Tag) {
400
- // Find Web Component tags and append -solarite-placeholder to their tag names
401
- // This way we can gather their constructor arguments and their children before we call their constructor.
402
- // Later, PathToComponent.apply() will replace them with the real components.
403
- // Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
404
- const isWebComponentTagName = /^<\/?[a-z][a-z0-9]*-[a-z0-9-]+/i; // a dash in the middle
405
- token = token.replace(isWebComponentTagName, match => match + '-SOLARITE-PLACEHOLDER'); // caps to match other instances of this string, for better compression.
521
+ let lastIndex = 0; // Start of the run of this chunk not yet copied into result.
522
+ for (let j = 0; j < html.length; j++) {
523
+ const char = html[j];
524
+
525
+ if (!inTag) {
526
+ if (char === '<' && html[j + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
527
+ inTag = true;
528
+
529
+ // A component suffix can only ever be added right here, at the '<' that opens the tag, so
530
+ // the name is matched on the spot with a sticky regex rather than collected into a buffer
531
+ // and matched later. The greedy tag-name class can't run past the name, because every
532
+ // character that can follow a tag name is outside it.
533
+ isWebComponentTagName.lastIndex = j;
534
+ let match = isWebComponentTagName.exec(html);
535
+ if (match) {
536
+ let end = j + match[0].length;
537
+ result += html.slice(lastIndex, end) + '-SOLARITE-PLACEHOLDER';
538
+ lastIndex = end;
539
+ }
406
540
  }
541
+ }
407
542
 
408
- result.push(token);
543
+ // Inside a tag, only two characters end anything: the quote that closes the value we're in, or,
544
+ // when we're not in one, the '>' that closes the tag. Attribute names, '=', unquoted values and
545
+ // whitespace all need no handling at all.
546
+ else if (quote) {
547
+ if (char === quote)
548
+ quote = null;
409
549
  }
410
- lastIndex = index;
411
- });
550
+ else if (char === '"' || char === "'")
551
+ quote = char;
552
+ else if (char === '>')
553
+ inTag = false;
554
+ }
555
+
556
+ result += html.slice(lastIndex);
412
557
 
413
558
  // Insert placeholders
414
- if (i < htmlChunks.length - 1) {
415
- if (context === HtmlParser.Text)
416
- result.push(commentPlaceholder) // Comment Placeholder. because we can't put text in between <tr> tags for example.
417
- else
418
- result.push(String.fromCharCode(attribPlaceholder + i));
419
- }
559
+ if (i < htmlChunks.length - 1)
560
+ result += inTag
561
+ ? String.fromCharCode(attribPlaceholder + i)
562
+ : commentPlaceholder; // Comment Placeholder. because we can't put text in between <tr> tags for example.
420
563
  }
421
564
 
422
- return result.join('');
565
+ return result;
423
566
  }
424
567
 
425
568
  /**
@@ -431,21 +574,18 @@ export default class Shell {
431
574
  * this.ids
432
575
  * this.staticComponents */
433
576
  findEmbeds() {
434
- this.scripts = Array.prototype.map.call(this.fragment.querySelectorAll('script'), el => Path.get(el))
577
+ this.scripts = Array.prototype.map.call(this.docFrag.querySelectorAll('script'), el => Path.get(el))
435
578
 
436
579
  // TODO: only find styles that have Paths in them?
437
- this.styles = Array.prototype.map.call(this.fragment.querySelectorAll('style'), el => Path.get(el))
438
-
439
- let idEls = this.fragment.querySelectorAll('[id],[data-id]');
440
-
441
- // Check for valid id names.
442
- for (let el of idEls) {
443
- let id = el.getAttribute('data-id') || el.getAttribute('id')
444
- if (Globals.div.hasOwnProperty(id))
445
- throw new Error(`<${el.tagName.toLowerCase()} id="${id}"> can't override existing HTMLElement id property.`)
446
- }
580
+ this.styles = Array.prototype.map.call(this.docFrag.querySelectorAll('style'), el => Path.get(el))
447
581
 
448
- this.ids = Array.prototype.map.call(idEls, el => Path.get(el))
582
+ // An id that would clobber a built-in element property is reported by Util.bindId(), which
583
+ // asks the real component object, with `in`, at the moment the binding happens. The check
584
+ // that used to stand here asked Globals.div.hasOwnProperty(id) instead, and a freshly
585
+ // created element has no own properties at all — every DOM property an element exposes
586
+ // lives on its interface prototype — so that test could never be true and the error it
587
+ // guarded was never reachable.
588
+ this.ids = Array.prototype.map.call(this.docFrag.querySelectorAll('[id],[data-id]'), el => Path.get(el))
449
589
 
450
590
  this.hasEmbeds = this.ids.length > 0 || this.styles.length > 0 || this.scripts.length > 0;
451
591
  }
@@ -456,25 +596,37 @@ export default class Shell {
456
596
  * Replaces per-path root-to-node walks in the hot NodeGroup creation path.
457
597
  * Skipped for shells with components, whose clone() has special attribPaths behavior. */
458
598
  buildResolveProgram() {
459
- let hasComponents = false;
460
- for (let path of this.paths)
461
- if (path instanceof PathToComponent) {
462
- hasComponents = true;
463
- break;
464
- }
465
- if (hasComponents || !this.paths.length)
599
+ if (this.hasComponentPaths || !this.paths.length)
466
600
  return;
467
601
 
468
602
  let ops = [];
469
603
  let slotOf = new Map();
470
- let frag = this.fragment;
604
+ let frag = this.docFrag;
471
605
  let nextSlot = 1;
472
606
  let getSlot = node => {
473
607
  if (node === frag)
474
608
  return 0;
475
609
  let s = slotOf.get(node);
476
610
  if (s === undefined) {
477
- ops.push(getSlot(node.parentNode), Array.prototype.indexOf.call(node.parentNode.childNodes, node));
611
+ // Two ways to reach a node, costing one pointer step each: walk forward from an
612
+ // already-resolved earlier sibling, or take the parent's firstChild and walk
613
+ // forward. Sibling steps win whenever they're no more numerous, and they can
614
+ // also spare the parent a slot of its own — in a row of cells, resolving each
615
+ // <td> from the previous one is one step instead of firstChild plus its index.
616
+ let d = 0, from = -1;
617
+ for (let sib = node.previousSibling; sib; sib = sib.previousSibling) {
618
+ d++;
619
+ let ss = slotOf.get(sib);
620
+ if (ss !== undefined) {
621
+ from = ss;
622
+ break;
623
+ }
624
+ }
625
+ let index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
626
+ if (from >= 0 && d <= index + 1)
627
+ ops.push(from, -d); // A negative step count means "walk nextSibling from that slot".
628
+ else
629
+ ops.push(getSlot(node.parentNode), index);
478
630
  s = nextSlot++;
479
631
  slotOf.set(node, s);
480
632
  }
@@ -485,10 +637,7 @@ export default class Shell {
485
637
  path.beforeSlot = path.nodeBefore ? getSlot(path.nodeBefore) : -1;
486
638
  }
487
639
 
488
- /** @type {?int[]} Flat [parentSlot, childIndex] pairs; pair i fills slot i+1. */
489
640
  this.resolveOps = ops;
490
-
491
- /** @type {Node[]} Reusable scratch array for resolved nodes; safe because resolution never re-enters. */
492
641
  this.resolveSlots = new Array(nextSlot);
493
642
 
494
643
  // A lone root element means slot 1 is always that element (the first op pair is [0, 0]),
@@ -523,15 +672,15 @@ export default class Shell {
523
672
  lastSvgMode = svgMode;
524
673
  lastShell = result;
525
674
 
526
- /*#IFDEV*/result.verify();/*#ENDIF*/
675
+ /*#IFDEBUG*/result.verify();/*#ENDIF*/
527
676
  return result;
528
677
  }
529
678
 
530
- //#IFDEV
679
+ //#IFDEBUG
531
680
  // For debugging only:
532
681
  verify() {
533
682
  for (let path of this.paths) {
534
- assert(this.fragment.contains(path.getParentNode()))
683
+ assert(this.docFrag.contains(path.getParentNode()))
535
684
  path.verify();
536
685
  }
537
686
  }
@@ -541,6 +690,15 @@ export default class Shell {
541
690
 
542
691
  const commentPlaceholder = `<!--!✨!-->`;
543
692
 
693
+ // A tag name with a dash in the middle, which is what makes an element a web component. addPlaceholders()
694
+ // tests this at each '<' that opens a tag, and a match gets -solarite-placeholder appended to its tag name.
695
+ // That way we can gather a component's constructor arguments and its children before we call its constructor;
696
+ // later PathToComponent.applyAll() replaces the placeholder tag with the real component. The suffix is written in
697
+ // caps wherever it appears, so that the several copies of it in this project compress well. It's sticky rather
698
+ // than anchored so it can be tested at an offset within the chunk instead of against a sliced-out token.
699
+ // Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
700
+ const isWebComponentTagName = /<\/?[a-z][a-z0-9]*-[a-z0-9-]+/iy;
701
+
544
702
  // Elements whose whitespace-only text children are never rendered.
545
703
  const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
546
704
 
@@ -560,6 +718,38 @@ function stripTableWhitespace(el) {
560
718
  }
561
719
  }
562
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
+
563
753
  // One-entry memo for Shell.get().
564
754
  let lastHtmlStrings = null, lastSvgMode = false, lastShell = null;
565
755