solarite 0.7.0 → 0.8.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,29 @@ 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);
113
170
 
114
171
  // 2. Find placeholders
115
172
  let node;
116
173
  let toRemove = [];
117
174
  let placeholdersUsed = 0;
118
- const walker = Globals.doc.createTreeWalker(this.fragment, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
175
+ const walker = Globals.doc.createTreeWalker(this.docFrag, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
119
176
  while (node = walker.nextNode()) {
120
177
 
121
178
  // Remove previous elements after each iteration, so paths will still be calculated correctly.
@@ -133,13 +190,20 @@ export default class Shell {
133
190
  // The reserved key attribute identifies this template within a keyed list.
134
191
  // It's consumed here and never written to the DOM or passed to components.
135
192
  if (attr.name === 'key') {
193
+
194
+ // These three are template-authoring mistakes, and every one of them fails SILENTLY if
195
+ // it isn't caught: the reconciler would key rows on a garbage value and reuse the wrong
196
+ // DOM, with nothing reported. So they ship, unlike the assertions elsewhere in this
197
+ // file. The cost is one regex split per unique template \u2014 never per render, never per
198
+ // row \u2014 which is why they are affordable to keep.
136
199
  let parts = attr.value.split(/[\ue000-\uf8ff]/g);
137
200
  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.`);
201
+ throw new Error(`Solarite: key must be one whole expression.`);
202
+ if (node.parentNode !== this.docFrag)
203
+ throw new Error(`Solarite: key must be on a top-level element.`);
141
204
  if (this.keyIndex >= 0)
142
- throw new Error(`A template can have only one key attribute.`);
205
+ throw new Error(`Solarite: duplicate key attribute.`);
206
+
143
207
  this.keyIndex = attr.value.charCodeAt(0) - attribPlaceholder;
144
208
 
145
209
  let path = new PathToKey(null, node);
@@ -184,19 +248,33 @@ export default class Shell {
184
248
  }
185
249
 
186
250
  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))
251
+ // An attribute whose whole value is one expression is removed from the shell:
252
+ // its stamped value is always the empty string, so every clone would carry a
253
+ // useless empty attribute that costs storage on creation and a slot in the
254
+ // element's attribute list forever, and apply() writes the real value anyway
255
+ // (a missing attribute reads back as '', so an empty expression still writes
256
+ // nothing). Event attributes must be removed for the same reason plus a
257
+ // stricter one: an empty onclick="" violates a strict CSP when the event fires.
258
+ // In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the
259
+ // placeholders stripped out makes the browser log parse errors, both here and
260
+ // when the fragment is cloned, so those are removed whether or not they're whole.
261
+ if (svgMode || !nonEmptyParts)
193
262
  node.removeAttribute(attr.name);
194
- else try {
263
+
264
+ // setAttribute throws only when the template author wrote a name the browser
265
+ // refuses, such as one holding a space or a quote. That name comes from a tagged
266
+ // template literal's static text, so it is a typo that surfaces the first time the
267
+ // template renders and can never appear later or for only some users. Development
268
+ // therefore wraps the call to rethrow with the attribute name and the tag included,
269
+ // because the browser's own DOMException names neither and leaves the author
270
+ // hunting. Production ships the bare call and lets that DOMException through: the
271
+ // friendlier wording is only worth its bytes to whoever can still fix the template.
272
+ else /*#IFDEBUG*/try {/*#ENDIF*/
195
273
  node.setAttribute(attr.name, parts.join(''));
196
- }
274
+ /*#IFDEBUG*/}
197
275
  catch (e) {
198
276
  throw new Error(`Error setting attribute "${attr.name}" on node <${node.tagName}>: ${e.message}`);
199
- }
277
+ }/*#ENDIF*/
200
278
  }
201
279
  }
202
280
  }
@@ -218,7 +296,7 @@ export default class Shell {
218
296
  else if (node.nodeType === 8 && node.nodeValue === '!✨!') {
219
297
 
220
298
  if (node?.parentNode?.closest && node?.parentNode?.closest('[contenteditable]'))
221
- throw new Error(`Contenteditable can't have expressions inside them. Use <div contenteditable value="\${...}"> instead.`);
299
+ throw new Error(`Solarite: no \${...} inside contenteditable; use value="\${...}".`);
222
300
 
223
301
  let parent = node.parentNode;
224
302
 
@@ -242,7 +320,7 @@ export default class Shell {
242
320
  nodeBefore = Globals.doc.createComment('Path:'+this.paths.length);
243
321
  node.parentNode.insertBefore(nodeBefore, node)
244
322
  }
245
- /*#IFDEV*/assert(nodeBefore);/*#ENDIF*/
323
+ /*#IFDEBUG*/assert(nodeBefore);/*#ENDIF*/
246
324
 
247
325
  // Get the next node.
248
326
  let nodeMarker;
@@ -257,7 +335,7 @@ export default class Shell {
257
335
  nodeMarker = node;
258
336
  nodeMarker.textContent = 'PathEnd:'+ this.paths.length;
259
337
  }
260
- /*#IFDEV*/assert(nodeMarker);/*#ENDIF*/
338
+ /*#IFDEBUG*/assert(nodeMarker);/*#ENDIF*/
261
339
 
262
340
  let path = new PathToNodes(nodeBefore, nodeMarker);
263
341
  this.paths.push(path);
@@ -265,11 +343,6 @@ export default class Shell {
265
343
  }
266
344
  }
267
345
 
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
346
  // Sometimes users will comment out a block of html code that has expressions.
274
347
  // Here we look for expressions in comments.
275
348
  // We don't actually update them dynamically, but we still add paths for them.
@@ -283,29 +356,39 @@ export default class Shell {
283
356
  }
284
357
  }
285
358
 
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 ++;
359
+ // A few elements have raw-text bodies, which the html parser reads as literal characters
360
+ // rather than as markup. A comment placeholder written inside one therefore never becomes
361
+ // a comment node; it arrives here as ordinary text. A textarea can't support expressions
362
+ // in its body at all, while script and style can, by splitting their text around each
363
+ // placeholder so that every expression gets a text node of its own to write into.
364
+ else if (node.nodeType === 3) { // Node.TEXT_NODE
365
+ let parentName = node.parentNode?.nodeName;
366
+
367
+ if (parentName === 'TEXTAREA' && node.textContent.includes(commentPlaceholder))
368
+ throw new Error(`Solarite: no \${...} inside textarea; use value="\${...}".`);
369
+
370
+ else if (parentName === 'SCRIPT' || parentName === 'STYLE') {
371
+ let parts = node.textContent.split(commentPlaceholder);
372
+ if (parts.length > 1) {
373
+
374
+ // Every part is inserted before the original node, in order, so from the second
375
+ // part onward the text node made on the previous iteration is already sitting
376
+ // immediately before this one and serves as the new path's nodeBefore.
377
+ for (let i = 0; i<parts.length; i++) {
378
+ let current = Globals.doc.createTextNode(parts[i]);
379
+ node.parentNode.insertBefore(current, node);
380
+ if (i > 0) {
381
+ let path = new PathToNodes(current.previousSibling, current);
382
+ this.paths.push(path);
383
+ placeholdersUsed ++;
384
+
385
+ /*#IFDEBUG*/path.verify();/*#ENDIF*/
386
+ }
387
+ }
303
388
 
304
- /*#IFDEV*/path.verify();/*#ENDIF*/
389
+ // Removing it here will mess up the treeWalker.
390
+ toRemove.push(node);
305
391
  }
306
-
307
- // Removing them here will mess up the treeWalker.
308
- toRemove.push(node);
309
392
  }
310
393
  }
311
394
  }
@@ -314,31 +397,37 @@ export default class Shell {
314
397
  // Less than or equal because there can be one path to multiple expressions
315
398
  // if those expressions are in the same attribute value.
316
399
  if (placeholdersUsed !== html.length-1)
317
- throw new Error(`Could not parse expressions in template. Check for duplicate attributes or malformed html: ${html.join('${...}')}`);
400
+ throw new Error(`Solarite: bad html or duplicate attribute: ${html.join('${...}')}`);
318
401
 
319
402
  for (let path of this.paths) {
320
- if (path.nodeBefore)
321
- path.nodeBeforeIndex = Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
403
+ // -1 when the path has no nodeBefore. Assigned unconditionally so every shell path
404
+ // of a given class takes the same property-addition order and shares one hidden class.
405
+ path.nodeBeforeIndex = path.nodeBefore
406
+ ? Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
407
+ : -1;
322
408
 
323
409
  // Must be calculated after we remove the toRemove nodes:
324
410
  path.nodeMarkerPath = Path.get(path.nodeMarker)
325
-
326
-
327
411
  }
328
412
 
329
413
  this.findEmbeds();
330
- this.buildResolveProgram();
331
414
 
415
+ // This scan must run before buildResolveProgram(), which skips shells with components
416
+ // and reads hasComponentPaths rather than walking the paths a second time.
332
417
  this.pathsSingleExpr = true;
333
418
  for (let path of this.paths) {
334
419
  if (path instanceof PathToComponent) {
335
420
  this.hasComponentPaths = true;
336
421
  this.pathsSingleExpr = false;
337
- break; // Both facts are now decided.
338
422
  }
339
- if (path.getExpressionCount() !== 1)
340
- this.pathsSingleExpr = false; // Keep scanning for components.
423
+ else if (path.getExpressionCount() !== 1)
424
+ this.pathsSingleExpr = false;
425
+ if (path.isHtmlProperty) // needs the full scan — no early break
426
+ this.hasLivePropPaths = true;
341
427
  }
428
+ this.needsRefresh = this.hasComponentPaths || (this.hasLivePropPaths && this.pathsSingleExpr);
429
+
430
+ this.buildResolveProgram();
342
431
 
343
432
  // Stampable shells create NodeGroups without allocating any Path objects:
344
433
  // NodeGroup.applyStamp() writes expressions through these shared stamper paths,
@@ -363,17 +452,50 @@ export default class Shell {
363
452
  }
364
453
  if (ok) {
365
454
  this.stampable = true;
366
-
367
- /** @type {int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
368
455
  this.nodesPathIdx = nodesIdx;
369
-
370
- /** @type {Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
371
456
  this.stampPaths = this.paths.map(p => p.cloneWithNodes(null, p.nodeMarker));
372
457
 
458
+ // Compiled stamp program: one opcode per path lets applyStamp() write a fresh
459
+ // row through a flat branch chain instead of dispatching applySingle() per path.
460
+ // 0 = generic (shared stamper fallback), 1 = list key (no DOM), 2 = wholeParent
461
+ // child text, 3 = delegatable single-expression event (written as node expandos
462
+ // when the root delegates, the default).
463
+ let n = this.paths.length;
464
+ this.stampOp = new Uint8Array(n);
465
+ this.stampSlot = new Uint16Array(n);
466
+ this.stampAux = new Array(n).fill(null);
467
+ this.stampFlags = new Uint8Array(n);
468
+
469
+ let eventNames = null;
470
+ for (let i=0; i<n; i++) {
471
+ let p = this.paths[i], sp = this.stampPaths[i];
472
+ this.stampSlot[i] = p.markerSlot;
473
+ this.stampFlags[i] = (sp.isHtmlProperty ? 1 : 0) | (sp.wholeParent ? 2 : 0);
474
+ if (p instanceof PathToKey)
475
+ this.stampOp[i] = 1;
476
+ else if (sp.wholeParent)
477
+ this.stampOp[i] = 2;
478
+ else if (sp instanceof PathToEvent && sp.delegatedKey !== undefined && !sp.attrValue) {
479
+ this.stampOp[i] = 3;
480
+ this.stampAux[i] = sp;
481
+ (eventNames ??= []).push(sp.eventName);
482
+ }
483
+
484
+ // A plain attribute holding one whole expression. The shell no longer carries
485
+ // the attribute at all (see the placeholder handling above), so on a freshly
486
+ // cloned row the value is known to be absent and a string can be written
487
+ // without first reading back what's there.
488
+ else if (sp instanceof PathToAttribValue && !sp.attrValue && !sp.isHtmlProperty
489
+ && !sp.isComponentAttrib) {
490
+ this.stampOp[i] = 4;
491
+ this.stampAux[i] = sp.attribName;
492
+ }
493
+ }
494
+ this.stampEventNames = eventNames;
373
495
  }
374
496
  }
375
497
 
376
- /*#IFDEV*/this.verify();/*#ENDIF*/
498
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
377
499
  }
378
500
 
379
501
  /**
@@ -384,42 +506,64 @@ export default class Shell {
384
506
  * @param htmlChunks {string[]}
385
507
  * @returns {string} Html with the placeholders in place. */
386
508
  static addPlaceholders(htmlChunks) {
387
- let result = [];
509
+ let result = '';
510
+
511
+ // Where the tokenizer is as it walks the chunks. An expression can sit in the middle of an attribute
512
+ // value, so both of these have to survive from one chunk to the next. Nothing else has to: an
513
+ // expression anywhere inside a tag gets the same attribute placeholder, so the machine only has to
514
+ // know whether it is inside a tag at all, and whether a quoted value is currently open.
515
+ let inTag = false; // True from the '<' that opens a tag or comment through the '>' that closes it.
516
+ let quote = null; // The quote character that opened the attribute value we're inside of: null, '"', or "'".
388
517
 
389
- let htmlParser = new HtmlParser(); // Reset the context.
390
518
  for (let i = 0; i < htmlChunks.length; i++) {
391
- let lastHtml = htmlChunks[i];
519
+ let html = htmlChunks[i];
392
520
 
393
521
  // 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.
522
+ let lastIndex = 0; // Start of the run of this chunk not yet copied into result.
523
+ for (let j = 0; j < html.length; j++) {
524
+ const char = html[j];
525
+
526
+ if (!inTag) {
527
+ if (char === '<' && html[j + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
528
+ inTag = true;
529
+
530
+ // A component suffix can only ever be added right here, at the '<' that opens the tag, so
531
+ // the name is matched on the spot with a sticky regex rather than collected into a buffer
532
+ // and matched later. The greedy tag-name class can't run past the name, because every
533
+ // character that can follow a tag name is outside it.
534
+ isWebComponentTagName.lastIndex = j;
535
+ let match = isWebComponentTagName.exec(html);
536
+ if (match) {
537
+ let end = j + match[0].length;
538
+ result += html.slice(lastIndex, end) + '-SOLARITE-PLACEHOLDER';
539
+ lastIndex = end;
540
+ }
406
541
  }
542
+ }
407
543
 
408
- result.push(token);
544
+ // Inside a tag, only two characters end anything: the quote that closes the value we're in, or,
545
+ // when we're not in one, the '>' that closes the tag. Attribute names, '=', unquoted values and
546
+ // whitespace all need no handling at all.
547
+ else if (quote) {
548
+ if (char === quote)
549
+ quote = null;
409
550
  }
410
- lastIndex = index;
411
- });
551
+ else if (char === '"' || char === "'")
552
+ quote = char;
553
+ else if (char === '>')
554
+ inTag = false;
555
+ }
556
+
557
+ result += html.slice(lastIndex);
412
558
 
413
559
  // 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
- }
560
+ if (i < htmlChunks.length - 1)
561
+ result += inTag
562
+ ? String.fromCharCode(attribPlaceholder + i)
563
+ : commentPlaceholder; // Comment Placeholder. because we can't put text in between <tr> tags for example.
420
564
  }
421
565
 
422
- return result.join('');
566
+ return result;
423
567
  }
424
568
 
425
569
  /**
@@ -431,21 +575,18 @@ export default class Shell {
431
575
  * this.ids
432
576
  * this.staticComponents */
433
577
  findEmbeds() {
434
- this.scripts = Array.prototype.map.call(this.fragment.querySelectorAll('script'), el => Path.get(el))
578
+ this.scripts = Array.prototype.map.call(this.docFrag.querySelectorAll('script'), el => Path.get(el))
435
579
 
436
580
  // TODO: only find styles that have Paths in them?
437
- this.styles = Array.prototype.map.call(this.fragment.querySelectorAll('style'), el => Path.get(el))
581
+ this.styles = Array.prototype.map.call(this.docFrag.querySelectorAll('style'), el => Path.get(el))
438
582
 
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
- }
447
-
448
- this.ids = Array.prototype.map.call(idEls, el => Path.get(el))
583
+ // An id that would clobber a built-in element property is reported by Util.bindId(), which
584
+ // asks the real component object, with `in`, at the moment the binding happens. The check
585
+ // that used to stand here asked Globals.div.hasOwnProperty(id) instead, and a freshly
586
+ // created element has no own properties at all — every DOM property an element exposes
587
+ // lives on its interface prototype — so that test could never be true and the error it
588
+ // guarded was never reachable.
589
+ this.ids = Array.prototype.map.call(this.docFrag.querySelectorAll('[id],[data-id]'), el => Path.get(el))
449
590
 
450
591
  this.hasEmbeds = this.ids.length > 0 || this.styles.length > 0 || this.scripts.length > 0;
451
592
  }
@@ -456,25 +597,37 @@ export default class Shell {
456
597
  * Replaces per-path root-to-node walks in the hot NodeGroup creation path.
457
598
  * Skipped for shells with components, whose clone() has special attribPaths behavior. */
458
599
  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)
600
+ if (this.hasComponentPaths || !this.paths.length)
466
601
  return;
467
602
 
468
603
  let ops = [];
469
604
  let slotOf = new Map();
470
- let frag = this.fragment;
605
+ let frag = this.docFrag;
471
606
  let nextSlot = 1;
472
607
  let getSlot = node => {
473
608
  if (node === frag)
474
609
  return 0;
475
610
  let s = slotOf.get(node);
476
611
  if (s === undefined) {
477
- ops.push(getSlot(node.parentNode), Array.prototype.indexOf.call(node.parentNode.childNodes, node));
612
+ // Two ways to reach a node, costing one pointer step each: walk forward from an
613
+ // already-resolved earlier sibling, or take the parent's firstChild and walk
614
+ // forward. Sibling steps win whenever they're no more numerous, and they can
615
+ // also spare the parent a slot of its own — in a row of cells, resolving each
616
+ // <td> from the previous one is one step instead of firstChild plus its index.
617
+ let d = 0, from = -1;
618
+ for (let sib = node.previousSibling; sib; sib = sib.previousSibling) {
619
+ d++;
620
+ let ss = slotOf.get(sib);
621
+ if (ss !== undefined) {
622
+ from = ss;
623
+ break;
624
+ }
625
+ }
626
+ let index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
627
+ if (from >= 0 && d <= index + 1)
628
+ ops.push(from, -d); // A negative step count means "walk nextSibling from that slot".
629
+ else
630
+ ops.push(getSlot(node.parentNode), index);
478
631
  s = nextSlot++;
479
632
  slotOf.set(node, s);
480
633
  }
@@ -485,10 +638,7 @@ export default class Shell {
485
638
  path.beforeSlot = path.nodeBefore ? getSlot(path.nodeBefore) : -1;
486
639
  }
487
640
 
488
- /** @type {?int[]} Flat [parentSlot, childIndex] pairs; pair i fills slot i+1. */
489
641
  this.resolveOps = ops;
490
-
491
- /** @type {Node[]} Reusable scratch array for resolved nodes; safe because resolution never re-enters. */
492
642
  this.resolveSlots = new Array(nextSlot);
493
643
 
494
644
  // A lone root element means slot 1 is always that element (the first op pair is [0, 0]),
@@ -523,15 +673,15 @@ export default class Shell {
523
673
  lastSvgMode = svgMode;
524
674
  lastShell = result;
525
675
 
526
- /*#IFDEV*/result.verify();/*#ENDIF*/
676
+ /*#IFDEBUG*/result.verify();/*#ENDIF*/
527
677
  return result;
528
678
  }
529
679
 
530
- //#IFDEV
680
+ //#IFDEBUG
531
681
  // For debugging only:
532
682
  verify() {
533
683
  for (let path of this.paths) {
534
- assert(this.fragment.contains(path.getParentNode()))
684
+ assert(this.docFrag.contains(path.getParentNode()))
535
685
  path.verify();
536
686
  }
537
687
  }
@@ -541,6 +691,15 @@ export default class Shell {
541
691
 
542
692
  const commentPlaceholder = `<!--!✨!-->`;
543
693
 
694
+ // A tag name with a dash in the middle, which is what makes an element a web component. addPlaceholders()
695
+ // tests this at each '<' that opens a tag, and a match gets -solarite-placeholder appended to its tag name.
696
+ // That way we can gather a component's constructor arguments and its children before we call its constructor;
697
+ // later PathToComponent.applyAll() replaces the placeholder tag with the real component. The suffix is written in
698
+ // caps wherever it appears, so that the several copies of it in this project compress well. It's sticky rather
699
+ // than anchored so it can be tested at an offset within the chunk instead of against a sliced-out token.
700
+ // Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
701
+ const isWebComponentTagName = /<\/?[a-z][a-z0-9]*-[a-z0-9-]+/iy;
702
+
544
703
  // Elements whose whitespace-only text children are never rendered.
545
704
  const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
546
705