solarite 0.7.1 → 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/dist/Solarite-debug.js +1945 -868
- package/dist/Solarite.js +1894 -823
- package/dist/Solarite.min.js +2 -2
- package/dist/jsx-dev-runtime.js +3 -0
- package/dist/jsx-runtime.js +86 -0
- package/package.json +8 -7
- package/readme.md +8 -8
- package/src/MappedList.js +34 -0
- package/src/MultiValueMap.js +4 -4
- package/src/NodeGroup.js +205 -119
- package/src/Path.js +52 -53
- package/src/PathToAttribValue.js +182 -101
- package/src/PathToAttribs.js +10 -18
- package/src/PathToComponent.js +43 -19
- package/src/PathToEvent.js +18 -12
- package/src/PathToKey.js +0 -6
- package/src/PathToNodes.js +695 -155
- package/src/RootNodeGroup.js +23 -30
- package/src/Selector.js +207 -0
- package/src/Shell.js +276 -117
- package/src/Solarite.d.ts +89 -13
- package/src/Solarite.js +38 -25
- package/src/Template.js +20 -7
- package/src/Util.js +75 -60
- package/src/assert.js +1 -1
- package/src/assignAttributes.js +20 -19
- package/src/h.js +64 -37
- package/src/jsx.js +4 -7
- package/src/toEl.js +1 -1
- package/src/HtmlParser.js +0 -91
- package/src/PathToComment.js +0 -8
package/dist/Solarite.js
CHANGED
|
@@ -155,13 +155,15 @@ let Util = {
|
|
|
155
155
|
// Don't clobber a non-element value. For a simple (non-nested) id this covers two cases:
|
|
156
156
|
// an inherited/built-in property like `title` or `style`, or an own property that already
|
|
157
157
|
// holds a non-Node value. A previously-bound element (a Node) is fine to re-assign.
|
|
158
|
+
// This can only fail on a mistake in the component's own template, so a developer meets it
|
|
159
|
+
// the first time the component renders and never again at runtime. It nonetheless SHIPS,
|
|
160
|
+
// and deliberately: debug-strip blocks are removed from dist/Solarite.js, which is what
|
|
161
|
+
// npm serves, so hiding it there would delete it for everyone, not only for production.
|
|
158
162
|
if (!id.includes('.')) {
|
|
159
163
|
let existing = root[id];
|
|
160
164
|
let isInherited = (id in root) && !Object.hasOwn(root, id);
|
|
161
165
|
if (!existing?.nodeType && (existing != null || isInherited))
|
|
162
|
-
throw new Error(
|
|
163
|
-
`<${el.tagName.toLowerCase()} id="${id}"> because it would clobber an existing ` +
|
|
164
|
-
`${isInherited ? 'built-in ' : ''}property. Rename the id or the property.`);
|
|
166
|
+
throw new Error(`Solarite: id="${id}" would overwrite an existing ${root.constructor.name} property.`);
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
delve(root, id.split(/\./g), el);
|
|
@@ -180,29 +182,29 @@ let Util = {
|
|
|
180
182
|
bindStyles(style, root) {
|
|
181
183
|
|
|
182
184
|
let tagName = root.tagName.toLowerCase();
|
|
183
|
-
|
|
185
|
+
|
|
186
|
+
// A global style is scoped by tag name alone, so it needs no attribute in the selector.
|
|
187
|
+
let attribSelector = '';
|
|
184
188
|
|
|
185
189
|
if (style.hasAttribute('global') || style.hasAttribute('data-global')) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (!doc.head.querySelector(`style[data-style="${styleId}"]`)) {
|
|
190
|
-
doc.head.append(style);
|
|
191
|
-
style.setAttribute('data-style', styleId);
|
|
192
|
-
}
|
|
193
|
-
else // TODO: Make sure the style has no expressions.
|
|
190
|
+
let head = Globals$1.doc.head;
|
|
191
|
+
if (head.querySelector(`style[data-style="${tagName}"]`))
|
|
192
|
+
// TODO: Make sure the style has no expressions.
|
|
194
193
|
style.remove(); // already in the head.
|
|
194
|
+
else {
|
|
195
|
+
head.append(style);
|
|
196
|
+
style.setAttribute('data-style', tagName);
|
|
197
|
+
}
|
|
195
198
|
}
|
|
196
199
|
else {
|
|
197
200
|
let styleId = root.getAttribute('data-style');
|
|
198
201
|
if (!styleId) {
|
|
199
|
-
// Keep track of one style id for each class.
|
|
202
|
+
// Keep track of one style id for each class. Reading the static walks up to a parent
|
|
203
|
+
// class's counter if this class has never been styled, but the assignment always lands
|
|
204
|
+
// on this class, so each class then counts on from where its parent left off.
|
|
200
205
|
// TODO: Put this outside the class in a map, so it doesn't conflict with static properties.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
styleId = root.constructor.styleId++;
|
|
204
|
-
|
|
205
|
-
root.setAttribute('data-style', styleId);
|
|
206
|
+
let Class = root.constructor;
|
|
207
|
+
root.setAttribute('data-style', styleId = Class.styleId = (Class.styleId || 0) + 1);
|
|
206
208
|
}
|
|
207
209
|
|
|
208
210
|
attribSelector = `[data-style="${styleId}"]`;
|
|
@@ -212,7 +214,19 @@ let Util = {
|
|
|
212
214
|
for (let child of style.childNodes) {
|
|
213
215
|
if (child.nodeType === 3) {
|
|
214
216
|
let oldText = child.textContent;
|
|
215
|
-
|
|
217
|
+
|
|
218
|
+
// One pass rewrites both forms of the selector:
|
|
219
|
+
// 1. The functional form ':host(X)' — the host element when it also matches X — unwraps
|
|
220
|
+
// so X sits right after the scoped name: tag[data-style="1"]X. X may hold one
|
|
221
|
+
// nested group like ':not(.open)'; deeper parentheses can't be paired by a regex,
|
|
222
|
+
// so such an X is left as written rather than half-rewritten into a selector the
|
|
223
|
+
// browser would discard silently.
|
|
224
|
+
// 2. Plain ':host'. The lookahead turns down longer names (':host-context') and '(',
|
|
225
|
+
// which only follows ':host' when alternative 1 already gave up on it, and accepts
|
|
226
|
+
// the end of the text node, where an expression may have split a dynamic style.
|
|
227
|
+
let newText = oldText.replace(
|
|
228
|
+
/:host(?:\(((?:[^()]|\([^()]*\))*)\)|(?![-a-z0-9_(]))/gi,
|
|
229
|
+
`${tagName}${attribSelector}$1`);
|
|
216
230
|
if (oldText !== newText)
|
|
217
231
|
child.textContent = newText;
|
|
218
232
|
}
|
|
@@ -233,17 +247,15 @@ let Util = {
|
|
|
233
247
|
* 'UIForm' => 'ui-form'
|
|
234
248
|
* 'A100' => 'a-100' */
|
|
235
249
|
camelToDashes(str) {
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// Convert all the remaining capital letters to lowercase.
|
|
246
|
-
return str.toLowerCase();
|
|
250
|
+
// One pass finds all three dash positions. Each alternative matches only the character
|
|
251
|
+
// *before* the boundary and uses a lookahead for what follows, so the following character
|
|
252
|
+
// is never consumed and can still start the next boundary. That's what lets the three
|
|
253
|
+
// rules interleave in a single scan the way three sequential replaces used to:
|
|
254
|
+
// 1. a lowercase letter or digit before a capital ('ProperName').
|
|
255
|
+
// 2. a capital before a capital+lowercase pair, i.e. the last capital of a run ('HTMLElement').
|
|
256
|
+
// 3. a letter before a digit ('A100').
|
|
257
|
+
// '$&-' appends the dash after the matched character, then everything folds to lowercase.
|
|
258
|
+
return str.replace(/[a-z0-9](?=[A-Z])|[A-Z](?=[A-Z][a-z])|[a-zA-Z](?=\d)/g, '$&-').toLowerCase();
|
|
247
259
|
},
|
|
248
260
|
|
|
249
261
|
/**
|
|
@@ -259,13 +271,24 @@ let Util = {
|
|
|
259
271
|
return str.replace(/-([a-z])/g, g => g[1].toUpperCase());
|
|
260
272
|
},
|
|
261
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Register Class as a custom element, unless it's registered already.
|
|
276
|
+
* @param Class {typeof HTMLElement}
|
|
277
|
+
* @param tagName {?string} Name to register under. Defaults to the dashed form of the class name.
|
|
278
|
+
* @return {string} The tag name Class is registered under, whether we just registered it or it
|
|
279
|
+
* was already in the registry under some other name. Callers that emit markup for the class
|
|
280
|
+
* use this instead of re-deriving the name, which guesses wrong for any class registered
|
|
281
|
+
* under a name that isn't camelToDashes(Class.name). */
|
|
262
282
|
defineClass(Class, tagName) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
283
|
+
let defined = customElements[getName](Class);
|
|
284
|
+
if (defined) // Previously defined.
|
|
285
|
+
return defined;
|
|
286
|
+
|
|
287
|
+
tagName = tagName || Util.camelToDashes(Class.name);
|
|
288
|
+
if (!tagName.includes('-')) // Browsers require that web components always have a dash in the name.
|
|
289
|
+
tagName += '-element';
|
|
290
|
+
customElements[define](tagName, Class);
|
|
291
|
+
return tagName;
|
|
269
292
|
},
|
|
270
293
|
|
|
271
294
|
/**
|
|
@@ -292,8 +315,8 @@ let Util = {
|
|
|
292
315
|
return node.value; // String
|
|
293
316
|
},
|
|
294
317
|
|
|
295
|
-
isEvent(
|
|
296
|
-
return
|
|
318
|
+
isEvent(attribName) {
|
|
319
|
+
return attribName.startsWith('on') && attribName in Globals$1.div;
|
|
297
320
|
},
|
|
298
321
|
|
|
299
322
|
/**
|
|
@@ -330,16 +353,13 @@ let Util = {
|
|
|
330
353
|
* @returns {Object} */
|
|
331
354
|
splitAttribs(str) {
|
|
332
355
|
let result = {};
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
value = (value || '').replace(/^(['"])(.*)\1$/, '$2'); // trim value quotes if they match.
|
|
341
|
-
result[name] = value;
|
|
342
|
-
}
|
|
356
|
+
|
|
357
|
+
// One scan collects every name and its value. The value is optional so a boolean attribute
|
|
358
|
+
// written on its own ('disabled') still lands in the result with an empty value, and the three
|
|
359
|
+
// value alternatives capture *inside* the quotes so no separate quote-trimming pass is needed.
|
|
360
|
+
// Whatever doesn't look like an attribute name is skipped rather than becoming a bogus key.
|
|
361
|
+
(str + '').replace(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g,
|
|
362
|
+
(_, name, dq, sq, bare) => result[name] = dq ?? sq ?? bare ?? '');
|
|
343
363
|
|
|
344
364
|
return result;
|
|
345
365
|
},
|
|
@@ -377,19 +397,14 @@ let Util = {
|
|
|
377
397
|
* @param nodes {Node[]|NodeList}
|
|
378
398
|
* @returns {Node[]} */
|
|
379
399
|
trimEmptyNodes(nodes) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
400
|
+
// nodeType 1 is an element and 3 is a text node; the literals are what Node.ELEMENT_NODE
|
|
401
|
+
// and Node.TEXT_NODE are defined as, and they cost a fraction of the bytes.
|
|
402
|
+
let isEmpty = node => node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim());
|
|
383
403
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
// Trim from the start
|
|
388
|
-
while (result.length > 0 && shouldTrimNode(result[0]))
|
|
404
|
+
let result = [...nodes]; // A NodeList can't shift() or pop().
|
|
405
|
+
while (result.length && isEmpty(result[0]))
|
|
389
406
|
result.shift();
|
|
390
|
-
|
|
391
|
-
// Trim from the end
|
|
392
|
-
while (result.length > 0 && shouldTrimNode(result[result.length - 1]))
|
|
407
|
+
while (result.length && isEmpty(result[result.length - 1]))
|
|
393
408
|
result.pop();
|
|
394
409
|
|
|
395
410
|
return result;
|
|
@@ -446,6 +461,23 @@ class Path {
|
|
|
446
461
|
* @type {Node[]} Cached result of getNodes() */
|
|
447
462
|
nodesCache;
|
|
448
463
|
|
|
464
|
+
/** @type {boolean|undefined} True when this path provides an attribute of a web component
|
|
465
|
+
* (a -solarite-placeholder element). Only attribute paths ever set it true, but it's
|
|
466
|
+
* declared here on every Path because clone() and cloneWithNodes() copy it to every clone;
|
|
467
|
+
* declaring it keeps those stores from transitioning the clone's hidden class. */
|
|
468
|
+
isComponentAttrib;
|
|
469
|
+
|
|
470
|
+
/** @type {boolean} True when re-applying an expression identical to the one already
|
|
471
|
+
* applied is provably a no-op, so a re-render can skip this path entirely. Only event
|
|
472
|
+
* bindings qualify: binding the same handler to the same node again changes nothing,
|
|
473
|
+
* while an attribute or a child expression may have been altered outside the template. */
|
|
474
|
+
skipIfSame = false;
|
|
475
|
+
|
|
476
|
+
/** @type {boolean|undefined} True when the attribute is a live HTML property
|
|
477
|
+
* (checked/value/selected — Util.isHtmlProp), which users can flip underneath the
|
|
478
|
+
* template. Declared here for the same hidden-class reason as isComponentAttrib. */
|
|
479
|
+
isHtmlProperty;
|
|
480
|
+
|
|
449
481
|
// Set only on Shell paths, never on cloned instances, so they're not declared as
|
|
450
482
|
// class fields; that would cost a store per field on every clone:
|
|
451
483
|
// nodeBeforeIndex {int} Index of nodeBefore among its parentNode's children.
|
|
@@ -481,7 +513,10 @@ class Path {
|
|
|
481
513
|
* [[expr5], [expr6, expr7]] // arguments to second my-component constructor.
|
|
482
514
|
* [expr5] // user attribute value.
|
|
483
515
|
* [expr6, expr7] // role attribute value. */
|
|
484
|
-
|
|
516
|
+
applyAll(exprs) {
|
|
517
|
+
|
|
518
|
+
this.applySingle(exprs[0]);
|
|
519
|
+
}
|
|
485
520
|
|
|
486
521
|
/**
|
|
487
522
|
* Fast path used by NodeGroup.applyExprs() when every path consumes exactly one expression.
|
|
@@ -491,24 +526,12 @@ class Path {
|
|
|
491
526
|
|
|
492
527
|
getExpressionCount() { return 1 }
|
|
493
528
|
|
|
494
|
-
|
|
495
529
|
/**
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
let pathLength = path.length - pathOffset;
|
|
502
|
-
for (let i=pathLength-1; i>0; i--) { // Resolve the path.
|
|
503
|
-
|
|
504
|
-
root = root.childNodes[path[i]];
|
|
505
|
-
}
|
|
506
|
-
let childNodes = root.childNodes;
|
|
507
|
-
|
|
508
|
-
return pathLength
|
|
509
|
-
? childNodes[path[0]]
|
|
510
|
-
: newRoot;
|
|
511
|
-
}
|
|
530
|
+
* The value a path hands to a component constructor, for the single-expression paths.
|
|
531
|
+
* PathToAttribValue overrides this to join its surrounding static strings.
|
|
532
|
+
* @param exprs {Expr[]}
|
|
533
|
+
* @return {Expr} */
|
|
534
|
+
getValue(exprs) { return exprs[0] }
|
|
512
535
|
|
|
513
536
|
|
|
514
537
|
/**
|
|
@@ -518,7 +541,7 @@ class Path {
|
|
|
518
541
|
* @param nodeMarker {Node}
|
|
519
542
|
* @return {Path} */
|
|
520
543
|
cloneWithNodes(nodeBefore, nodeMarker) {
|
|
521
|
-
let result = new this.constructor(nodeBefore, nodeMarker, this.
|
|
544
|
+
let result = new this.constructor(nodeBefore, nodeMarker, this.attribName, this.attrValue);
|
|
522
545
|
result.isComponentAttrib = this.isComponentAttrib;
|
|
523
546
|
result.wholeParent = this.wholeParent;
|
|
524
547
|
result.isHtmlProperty = this.isHtmlProperty;
|
|
@@ -532,33 +555,19 @@ class Path {
|
|
|
532
555
|
clone(newRoot, pathOffset=0) {
|
|
533
556
|
|
|
534
557
|
|
|
535
|
-
// Resolve node paths.
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
let
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
root = root.childNodes[path[i]];
|
|
543
|
-
}
|
|
544
|
-
let childNodes = root.childNodes;
|
|
545
|
-
|
|
546
|
-
nodeMarker = pathLength
|
|
547
|
-
? childNodes[path[0]]
|
|
548
|
-
: newRoot;
|
|
558
|
+
// Resolve node paths. nodeBefore is always a sibling of nodeMarker (Shell builds it from
|
|
559
|
+
// nodeMarker.previousSibling, or inserts a comment immediately before it), so the list
|
|
560
|
+
// nodeBeforeIndex counts within is the marker's own parent's childNodes. An empty path
|
|
561
|
+
// leaves the marker as newRoot itself, and then that list is newRoot's children.
|
|
562
|
+
let nodeBefore;
|
|
563
|
+
let nodeMarker = Path.resolve(newRoot, this.nodeMarkerPath, pathOffset);
|
|
549
564
|
if (this.nodeBefore) {
|
|
565
|
+
let childNodes = (nodeMarker === newRoot ? newRoot : nodeMarker.parentNode).childNodes;
|
|
550
566
|
|
|
551
567
|
nodeBefore = childNodes[this.nodeBeforeIndex];
|
|
552
|
-
|
|
553
568
|
}
|
|
554
569
|
|
|
555
|
-
let result =
|
|
556
|
-
|
|
557
|
-
result.isComponentAttrib = this.isComponentAttrib;
|
|
558
|
-
result.wholeParent = this.wholeParent;
|
|
559
|
-
|
|
560
|
-
// TODO: Put this in PathToAttribValue.clone().
|
|
561
|
-
result.isHtmlProperty = this.isHtmlProperty;
|
|
570
|
+
let result = this.cloneWithNodes(nodeBefore, nodeMarker);
|
|
562
571
|
|
|
563
572
|
|
|
564
573
|
|
|
@@ -582,131 +591,250 @@ class Path {
|
|
|
582
591
|
* Note that the path is backward, with the outermost element at the end.
|
|
583
592
|
* @param root {HTMLElement|Document|DocumentFragment|ParentNode}
|
|
584
593
|
* @param path {int[]}
|
|
594
|
+
* @param skip {int} How many of the outermost steps to leave off, for when root is
|
|
595
|
+
* already that many levels down from where the path was recorded. An empty walk
|
|
596
|
+
* (skip === path.length) returns root itself.
|
|
585
597
|
* @returns {Node|HTMLElement|HTMLStyleElement} */
|
|
586
|
-
static resolve(root, path) {
|
|
587
|
-
for (let i=path.length-1; i>=0; i--)
|
|
598
|
+
static resolve(root, path, skip=0) {
|
|
599
|
+
for (let i=path.length-1-skip; i>=0; i--) {
|
|
600
|
+
|
|
588
601
|
root = root.childNodes[path[i]];
|
|
602
|
+
}
|
|
589
603
|
return root;
|
|
590
604
|
}
|
|
591
605
|
|
|
592
606
|
|
|
593
607
|
}
|
|
594
608
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
609
|
+
/**
|
|
610
|
+
* A key-scoped selection that updates only the rows it actually affects.
|
|
611
|
+
*
|
|
612
|
+
* Rendering a list normally means calling render() and letting the reconciler decide what
|
|
613
|
+
* changed. That is the right default, but it is a poor fit for a selection: moving a
|
|
614
|
+
* highlight from one row of a thousand to another changes two attributes, and asking the
|
|
615
|
+
* reconciler about it means walking the whole list to discover that fact.
|
|
616
|
+
*
|
|
617
|
+
* A Selector short-circuits that. when() hands each row one of exactly two objects — the
|
|
618
|
+
* selected one or the unselected one — and set() reaches the two rows that change through
|
|
619
|
+
* the list they were rendered into, writing their attributes directly with no render() call.
|
|
620
|
+
*
|
|
621
|
+
* This is the same primitive as Solid's createSelector, adapted to a library that has no
|
|
622
|
+
* signals: the list, not a subscription, is what carries the binding.
|
|
623
|
+
*
|
|
624
|
+
* Because set() locates a row by its key, **the rows must be keyed** — the row template needs
|
|
625
|
+
* a key=${...} attribute. set() throws on an unkeyed list rather than silently doing nothing.
|
|
626
|
+
*/
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The value an attribute is bound to. There are only ever **two** of these per Selector,
|
|
630
|
+
* both built in its constructor: one standing for "this row is the selected one" and one for
|
|
631
|
+
* "this row is not". when() returns whichever of the two the row's key calls for.
|
|
632
|
+
*
|
|
633
|
+
* Two singletons rather than one object per key is what makes a selector free to create. A
|
|
634
|
+
* row of a freshly-drawn list with nothing selected gets the unselected singleton, whose
|
|
635
|
+
* value is the off value, so there is no allocation, no map entry and no DOM call — only the
|
|
636
|
+
* two stores that record where the list lives. It also sharpens the re-render skip: a row's
|
|
637
|
+
* expression changes identity exactly when its selectedness changes, so
|
|
638
|
+
* NodeGroup.rewriteStamp() rewrites the rows that gained or lost the selection and no others.
|
|
639
|
+
*/
|
|
640
|
+
class SelectorRef {
|
|
641
|
+
|
|
642
|
+
/** @type {Selector} */
|
|
643
|
+
selector;
|
|
644
|
+
|
|
645
|
+
/** @type {boolean} True on the singleton that stands for the selected row. */
|
|
646
|
+
selected;
|
|
647
|
+
|
|
648
|
+
constructor(selector, selected) {
|
|
649
|
+
this.selector = selector;
|
|
650
|
+
this.selected = selected;
|
|
604
651
|
}
|
|
605
652
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
653
|
+
/** @return {*} The value this ref currently stands for. */
|
|
654
|
+
value() {
|
|
655
|
+
let s = this.selector;
|
|
656
|
+
return this.selected ? s.onValue : s.offValue;
|
|
609
657
|
}
|
|
610
658
|
|
|
611
659
|
/**
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
this.state.buffer = ''; // Reset the buffer when a delimiter or potential self-closing sign is found.
|
|
644
|
-
}
|
|
645
|
-
else if (char === '"' || char === "'" || char === '=') {
|
|
646
|
-
onContextChange?.(html, i, this.state.context, HtmlParser.Attribute);
|
|
647
|
-
this.state.context = HtmlParser.Attribute;
|
|
648
|
-
this.state.quote = char === '=' ? null : char;
|
|
649
|
-
this.state.buffer = '';
|
|
650
|
-
}
|
|
651
|
-
else
|
|
652
|
-
this.state.buffer += char;
|
|
653
|
-
break;
|
|
654
|
-
case HtmlParser.Attribute:
|
|
655
|
-
// Start an attribute quote.
|
|
656
|
-
if (!this.state.quote && !this.state.buffer.length && (char === '"' || char === "'")) {
|
|
657
|
-
this.state.quote = char;
|
|
658
|
-
}
|
|
659
|
-
else if (char === this.state.quote || (!this.state.quote && this.state.buffer.length)) {
|
|
660
|
-
onContextChange?.(html, i, this.state.context, HtmlParser.Tag);
|
|
661
|
-
this.state.context = HtmlParser.Tag;
|
|
662
|
-
this.state.quote = null;
|
|
663
|
-
this.state.buffer = '';
|
|
664
|
-
}
|
|
665
|
-
else if (!this.state.quote && char === '>') {
|
|
666
|
-
onContextChange?.(html, i+1, this.state.context, HtmlParser.Text);
|
|
667
|
-
this.state.context = HtmlParser.Text;
|
|
668
|
-
this.state.quote = null;
|
|
669
|
-
this.state.buffer = '';
|
|
670
|
-
}
|
|
671
|
-
else if (char !== ' ')
|
|
672
|
-
this.state.buffer += char;
|
|
673
|
-
|
|
674
|
-
break;
|
|
675
|
-
}
|
|
660
|
+
* Write this ref's value to an element's attribute, and tell the selector where the list
|
|
661
|
+
* is so that a later set() can find any row in it.
|
|
662
|
+
*
|
|
663
|
+
* Called by PathToAttribValue when the ref appears as an attribute expression. It runs
|
|
664
|
+
* once per row per render, so it is deliberately nothing but two stores and a write that
|
|
665
|
+
* the common case skips.
|
|
666
|
+
*
|
|
667
|
+
* @param node {Node} The element carrying the attribute.
|
|
668
|
+
* @param attribName {string}
|
|
669
|
+
* @param parentNg {NodeGroup} The row this attribute belongs to. */
|
|
670
|
+
bind(node, attribName, parentNg) {
|
|
671
|
+
// set() writes through the row's own root element, so an attribute anywhere deeper
|
|
672
|
+
// would be found at bind time and then written somewhere else at set() time. Catching
|
|
673
|
+
// it here turns a silently misplaced attribute into a clear message. It SHIPS: it is not
|
|
674
|
+
// in a debug-strip block, and it must not be, because the failure it catches is silent.
|
|
675
|
+
if (parentNg.startNode !== node)
|
|
676
|
+
throw new Error(`Solarite: a selector must be on the row's root element.`);
|
|
677
|
+
|
|
678
|
+
let s = this.selector;
|
|
679
|
+
s.attribName = attribName;
|
|
680
|
+
s.path = parentNg.parentPath;
|
|
681
|
+
|
|
682
|
+
let v = this.selected ? s.onValue : s.offValue;
|
|
683
|
+
|
|
684
|
+
// Matches PathToAttribValue.applySingle: an empty or falsy value leaves no attribute
|
|
685
|
+
// behind, so a selector never adds markup a hand-written implementation wouldn't have.
|
|
686
|
+
if (v === '' || v === false || v === null || v === undefined) {
|
|
687
|
+
// A just-cloned row provably carries no attribute of this name yet, so the
|
|
688
|
+
// removeAttribute — a DOM call for every row of the list — can be skipped.
|
|
689
|
+
if (parentNg.firstApply !== true)
|
|
690
|
+
node.removeAttribute(attribName);
|
|
676
691
|
}
|
|
677
|
-
|
|
678
|
-
|
|
692
|
+
else
|
|
693
|
+
node.setAttribute(attribName, v);
|
|
679
694
|
}
|
|
680
695
|
}
|
|
681
696
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
697
|
+
/**
|
|
698
|
+
* Created by h.selector(). Holds one selected key.
|
|
699
|
+
*
|
|
700
|
+
* Only attribute expressions can bind a selector; using one as element content throws,
|
|
701
|
+
* because writing text through this path would need bookkeeping the two-node fast case
|
|
702
|
+
* doesn't want.
|
|
703
|
+
*
|
|
704
|
+
* The selector keeps **no per-row state at all** — no map of keys, nothing to sweep, and
|
|
705
|
+
* nothing that could pin a removed row's element in memory. All it remembers is which
|
|
706
|
+
* attribute it drives and which list it was rendered into.
|
|
707
|
+
*/
|
|
708
|
+
class Selector {
|
|
709
|
+
|
|
710
|
+
/** @type {*} The selected key, or null. */
|
|
711
|
+
#key = null;
|
|
712
|
+
|
|
713
|
+
/** @type {SelectorRef} Returned by when() for the row whose key is selected. */
|
|
714
|
+
#on = new SelectorRef(this, true);
|
|
715
|
+
|
|
716
|
+
/** @type {SelectorRef} Returned by when() for every other row. */
|
|
717
|
+
#off = new SelectorRef(this, false);
|
|
718
|
+
|
|
719
|
+
/** @type {*} Value the bound attribute takes for the selected key. Held here rather than
|
|
720
|
+
* on each ref, so the two refs stay interchangeable between call sites. */
|
|
721
|
+
onValue;
|
|
722
|
+
|
|
723
|
+
/** @type {*} Value it takes for every other key. */
|
|
724
|
+
offValue = '';
|
|
725
|
+
|
|
726
|
+
/** @type {?string} The attribute this selector drives, learned when a row binds. */
|
|
727
|
+
attribName = null;
|
|
728
|
+
|
|
729
|
+
/** @type {?PathToNodes} The list this selector's rows were rendered into, learned when a
|
|
730
|
+
* row binds. set() asks it for the NodeGroup holding a given key. */
|
|
731
|
+
path = null;
|
|
732
|
+
|
|
733
|
+
/** @param key {*} The initially selected key. */
|
|
734
|
+
constructor(key = null) {
|
|
735
|
+
this.#key = key;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** @return {*} The selected key. */
|
|
739
|
+
get key() {
|
|
740
|
+
return this.#key;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Bind an attribute to whether key is the selected one.
|
|
745
|
+
*
|
|
746
|
+
* h`<tr key=${row.id} class=${sel.when(row.id, 'danger')}>`
|
|
747
|
+
*
|
|
748
|
+
* @param key {*} This row's key.
|
|
749
|
+
* @param on {*} Value the attribute takes when key is selected.
|
|
750
|
+
* @param off {*} Value it takes otherwise. '' removes the attribute.
|
|
751
|
+
* @return {SelectorRef} */
|
|
752
|
+
when(key, on, off = '') {
|
|
753
|
+
this.onValue = on;
|
|
754
|
+
this.offValue = off;
|
|
755
|
+
return key === this.#key ? this.#on : this.#off;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Move the selection. Writes at most two attributes — the row losing the selection and
|
|
760
|
+
* the row gaining it — and touches nothing else. There is no render() call.
|
|
761
|
+
* @param key {*} The newly selected key, or null for none. */
|
|
762
|
+
set(key) {
|
|
763
|
+
let old = this.#key;
|
|
764
|
+
if (old === key)
|
|
765
|
+
return;
|
|
766
|
+
this.#key = key;
|
|
767
|
+
|
|
768
|
+
// Nothing has rendered a row yet, so there is no list to write into. The new key
|
|
769
|
+
// still takes effect: rows drawn later come up already carrying the attribute.
|
|
770
|
+
if (this.path === null)
|
|
771
|
+
return;
|
|
772
|
+
|
|
773
|
+
this.#write(old, this.offValue);
|
|
774
|
+
this.#write(key, this.onValue);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Find the row holding key and give its root element the value v.
|
|
779
|
+
* @param key {*}
|
|
780
|
+
* @param v {*} */
|
|
781
|
+
#write(key, v) {
|
|
782
|
+
if (key === null || key === undefined)
|
|
783
|
+
return;
|
|
784
|
+
|
|
785
|
+
let ngs = this.path.nodeGroups;
|
|
786
|
+
if (ngs === null || ngs.length === 0)
|
|
787
|
+
return;
|
|
788
|
+
|
|
789
|
+
if (ngs[0].key === undefined)
|
|
790
|
+
throw new Error('Solarite: a selector must be on a keyed list, as key=${...}.');
|
|
791
|
+
|
|
792
|
+
// A linear scan over the rows. The list is walked only when the selection actually
|
|
793
|
+
// moves — twice per user click, not once per row per render — so a thousand pointer
|
|
794
|
+
// comparisons here cost far less than the per-row index that would avoid them.
|
|
795
|
+
let ng = null;
|
|
796
|
+
for (let i = 0; i < ngs.length; i++)
|
|
797
|
+
if (ngs[i].key === key) {
|
|
798
|
+
ng = ngs[i];
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
if (ng === null)
|
|
802
|
+
return;
|
|
803
|
+
|
|
804
|
+
// The selector owns an attribute on the row's own root element, which for a
|
|
805
|
+
// single-root row template is exactly the NodeGroup's startNode.
|
|
806
|
+
let node = ng.startNode;
|
|
807
|
+
if (node === null || node.nodeType !== 1)
|
|
808
|
+
return;
|
|
809
|
+
|
|
810
|
+
if (v === '' || v === false || v === null || v === undefined)
|
|
811
|
+
node.removeAttribute(this.attribName);
|
|
812
|
+
else
|
|
813
|
+
node.setAttribute(this.attribName, v);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
685
816
|
|
|
686
817
|
class PathToAttribValue extends Path {
|
|
687
818
|
|
|
688
819
|
/** @type {?string} Used only if type=AttribType.Value. */
|
|
689
|
-
|
|
820
|
+
attribName;
|
|
690
821
|
|
|
691
822
|
/**
|
|
692
823
|
* @type {?string[]} Used only if type=AttribType.Value. If null, use one expr to set the whole attribute value. */
|
|
693
824
|
attrValue;
|
|
694
825
|
|
|
695
|
-
|
|
696
|
-
isComponent;
|
|
697
|
-
|
|
698
|
-
isHtmlProperty;
|
|
826
|
+
// isComponentAttrib and isHtmlProperty are declared on the Path base class.
|
|
699
827
|
|
|
700
|
-
constructor(nodeBefore, nodeMarker,
|
|
828
|
+
constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
|
|
701
829
|
super(null, nodeMarker);
|
|
702
|
-
this.
|
|
830
|
+
this.attribName = attribName;
|
|
703
831
|
this.attrValue = attrValue;
|
|
704
832
|
}
|
|
705
833
|
|
|
706
834
|
/**
|
|
707
835
|
* Set the value of an attribute. This can be for any attribute, not just attributes named "value".
|
|
708
836
|
* @param exprs {Expr[]} */
|
|
709
|
-
|
|
837
|
+
applyAll(exprs) {
|
|
710
838
|
|
|
711
839
|
|
|
712
840
|
// Multiple expressions in one attribute value, e.g. class="a ${b} c ${d}"
|
|
@@ -718,14 +846,14 @@ class PathToAttribValue extends Path {
|
|
|
718
846
|
// Only update attributes if the value has changed.
|
|
719
847
|
// This is needed for setting input.value, .checked, option.selected, etc.
|
|
720
848
|
let oldVal = isProp
|
|
721
|
-
? node[this.
|
|
722
|
-
: node.getAttribute(this.
|
|
849
|
+
? node[this.attribName]
|
|
850
|
+
: node.getAttribute(this.attribName);
|
|
723
851
|
if (oldVal !== joinedValue) {
|
|
724
852
|
if (isProp)
|
|
725
|
-
node[this.
|
|
726
|
-
else if (this.
|
|
853
|
+
node[this.attribName] = joinedValue;
|
|
854
|
+
else if (this.attribName === 'value' && node.hasAttribute('contenteditable'))
|
|
727
855
|
node.innerHTML = joinedValue;
|
|
728
|
-
node.setAttribute(this.
|
|
856
|
+
node.setAttribute(this.attribName, joinedValue);
|
|
729
857
|
}
|
|
730
858
|
}
|
|
731
859
|
else
|
|
@@ -738,7 +866,7 @@ class PathToAttribValue extends Path {
|
|
|
738
866
|
applySingle(expr) {
|
|
739
867
|
// One expression surrounded by strings, e.g. class="a ${b} c". Join through apply().
|
|
740
868
|
if (this.attrValue)
|
|
741
|
-
return this.
|
|
869
|
+
return this.applyAll([expr]);
|
|
742
870
|
|
|
743
871
|
let node = this.nodeMarker;
|
|
744
872
|
|
|
@@ -759,12 +887,12 @@ class PathToAttribValue extends Path {
|
|
|
759
887
|
let [obj, path] = [expr[0], expr.slice(1)];
|
|
760
888
|
|
|
761
889
|
if (!obj)
|
|
762
|
-
throw new Error(`Solarite cannot bind
|
|
890
|
+
throw new Error(`Solarite cannot bind ${this.attribName} to ${obj}.`);
|
|
763
891
|
|
|
764
892
|
let value = delve(obj, path);
|
|
765
893
|
|
|
766
894
|
// Special case to allow setting select-multiple value from an array
|
|
767
|
-
if (this.
|
|
895
|
+
if (this.attribName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
|
|
768
896
|
// Set the .selected property on the options having a value within value.
|
|
769
897
|
let strValues = value.map(v => v + '');
|
|
770
898
|
for (let option of node.options)
|
|
@@ -780,7 +908,7 @@ class PathToAttribValue extends Path {
|
|
|
780
908
|
const strValue = Util.isFalsy(value) ? '' : value;
|
|
781
909
|
|
|
782
910
|
// Special case for contenteditable
|
|
783
|
-
if (this.
|
|
911
|
+
if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
|
|
784
912
|
const existingValue = node.innerHTML;
|
|
785
913
|
if (strValue !== existingValue)
|
|
786
914
|
node.innerHTML = strValue;
|
|
@@ -789,28 +917,39 @@ class PathToAttribValue extends Path {
|
|
|
789
917
|
|
|
790
918
|
// If we don't have this condition, when we call render(), the browser will scroll to the currently
|
|
791
919
|
// selected item in a <select> and mess up manually scrolling to a different value.
|
|
792
|
-
if (strValue !== node[this.
|
|
793
|
-
node[this.
|
|
920
|
+
if (strValue !== node[this.attribName])
|
|
921
|
+
node[this.attribName] = strValue;
|
|
794
922
|
}
|
|
795
923
|
}
|
|
796
924
|
|
|
797
925
|
// TODO: We need to remove any old listeners, like in bindEventAttribute.
|
|
798
926
|
// Does bindEvent() now handle that?
|
|
799
927
|
let func = () => {
|
|
800
|
-
let value = (this.
|
|
928
|
+
let value = (this.attribName === 'value' || node.type === 'radio')
|
|
801
929
|
? Util.getInputValue(node)
|
|
802
|
-
: node[this.
|
|
930
|
+
: node[this.attribName];
|
|
803
931
|
delve(obj, path, value);
|
|
804
932
|
};
|
|
805
933
|
|
|
806
934
|
// We use capture so we update the values before other events added by the user.
|
|
807
935
|
// TODO: Bind to scroll events also?
|
|
808
936
|
// What about resize events and width/height?
|
|
809
|
-
this.bindEvent(node, this.parentNg.
|
|
937
|
+
this.bindEvent(node, this.parentNg.getRootEl(), this.attribName, 'input', func, null, true);
|
|
810
938
|
}
|
|
811
939
|
|
|
812
940
|
// Regular attribute
|
|
813
941
|
else {
|
|
942
|
+
// A selection binding (h.selector().when()) writes its own value and tells the
|
|
943
|
+
// selector which list this row belongs to, so a later change of selection reaches
|
|
944
|
+
// the attribute directly instead of going back through render(). The typeof test
|
|
945
|
+
// keeps ordinary string attributes — nearly all of them — from paying for the
|
|
946
|
+
// prototype check.
|
|
947
|
+
if (typeof expr === 'object' && expr instanceof SelectorRef) {
|
|
948
|
+
if (!this.isComponentAttrib)
|
|
949
|
+
expr.bind(node, this.attribName, this.parentNg);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
814
953
|
// Cache this on Path.isHtmlProperty when Shell creates the props.
|
|
815
954
|
// Have Path.clone() copy .isHtmlProperty?
|
|
816
955
|
let isProp = this.isHtmlProperty;
|
|
@@ -823,43 +962,53 @@ class PathToAttribValue extends Path {
|
|
|
823
962
|
else
|
|
824
963
|
expr = Util.makePrimitive(expr);
|
|
825
964
|
|
|
826
|
-
// Values
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
965
|
+
// Values that remove an attribute. The empty string is included so that an attribute
|
|
966
|
+
// disappears whenever its expression is empty, instead of only when it happened to be
|
|
967
|
+
// absent already. makePrimitive() above turns null into '', so plain null lands here
|
|
968
|
+
// too; the explicit null test still matters for a function expression returning null,
|
|
969
|
+
// which skips makePrimitive.
|
|
970
|
+
// An html property is exempt: on those, '' is a real value meaning "empty", as when
|
|
971
|
+
// clearing an <input>, so it belongs on the assignment path below.
|
|
972
|
+
if (expr === undefined || expr === false || expr === null || (expr === '' && !isProp)) {
|
|
973
|
+
if (isProp) {
|
|
974
|
+
// Clear the property with a value of its own type. Assigning false to a string
|
|
975
|
+
// property such as input.value would put the text "false" in the field.
|
|
976
|
+
let old = node[this.attribName];
|
|
977
|
+
node[this.attribName] = typeof old === 'boolean' ? false : '';
|
|
978
|
+
}
|
|
979
|
+
node.removeAttribute(this.attribName);
|
|
831
980
|
}
|
|
832
981
|
else if (expr === true) {
|
|
833
982
|
if (isProp)
|
|
834
|
-
node[this.
|
|
835
|
-
node.setAttribute(this.
|
|
983
|
+
node[this.attribName] = true;
|
|
984
|
+
node.setAttribute(this.attribName, '');
|
|
836
985
|
}
|
|
837
986
|
|
|
838
987
|
// A non-toggled attribute
|
|
839
988
|
else {
|
|
840
989
|
// Only update attributes if the value has changed.
|
|
841
990
|
// This is needed for setting input.value, .checked, option.selected, etc.
|
|
842
|
-
//
|
|
991
|
+
// Non-property attributes never reach here with '', since that removes above.
|
|
843
992
|
let oldVal = isProp
|
|
844
|
-
? node[this.
|
|
845
|
-
: node.getAttribute(this.
|
|
993
|
+
? node[this.attribName]
|
|
994
|
+
: node.getAttribute(this.attribName) ?? '';
|
|
846
995
|
if (oldVal !== expr) {
|
|
847
996
|
|
|
848
997
|
// <textarea value=${expr}></textarea>
|
|
849
998
|
// Without this branch we have no way to set the value of a textarea,
|
|
850
999
|
// since we also prohibit expressions that are a child of textarea.
|
|
851
1000
|
if (isProp)
|
|
852
|
-
node[this.
|
|
1001
|
+
node[this.attribName] = expr;
|
|
853
1002
|
|
|
854
1003
|
// Allow one-way binding to contenteditable value attribute.
|
|
855
1004
|
// Contenteditables normally don't have a value attribute and have their content set via innerHTML.
|
|
856
1005
|
// Solarite doesn't allow contenteditables to have expressions as their children.
|
|
857
|
-
else if (this.
|
|
1006
|
+
else if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
|
|
858
1007
|
node.innerHTML = expr;
|
|
859
1008
|
}
|
|
860
1009
|
|
|
861
1010
|
// TODO: Putting an 'else' here would be more performant
|
|
862
|
-
node.setAttribute(this.
|
|
1011
|
+
node.setAttribute(this.attribName, expr);
|
|
863
1012
|
}
|
|
864
1013
|
}
|
|
865
1014
|
}
|
|
@@ -887,6 +1036,18 @@ class PathToAttribValue extends Path {
|
|
|
887
1036
|
for (let i = 0; i < values.length; i++) {
|
|
888
1037
|
result.push(values[i]);
|
|
889
1038
|
if (i < values.length - 1) {
|
|
1039
|
+
// A selection binding has to own the whole attribute, because its whole point is
|
|
1040
|
+
// writing that attribute without re-rendering, which it can't do if the rest of
|
|
1041
|
+
// the value comes from expressions it doesn't know about. Whether a selector sits
|
|
1042
|
+
// inside a multi-part attribute is fixed by the shape of the template and never by
|
|
1043
|
+
// the data, so this can only be an authoring mistake, and it always surfaces on the
|
|
1044
|
+
// template's very first render -- exactly like the placement check in
|
|
1045
|
+
// SelectorRef.bind(). That makes it safe to strip from the built file, where the
|
|
1046
|
+
// throw is the only thing lost: makePrimitive() then turns the ref into '' and the
|
|
1047
|
+
// attribute is written from its constant parts alone. Stripping it also keeps a
|
|
1048
|
+
// per-expression instanceof out of the multi-part attribute loop.
|
|
1049
|
+
if (typeof exprs[i] === 'object' && exprs[i] instanceof SelectorRef)
|
|
1050
|
+
throw new Error(`Solarite: a selector must own the whole ${this.attribName} attribute.`);
|
|
890
1051
|
let val = Util.makePrimitive(exprs[i]);
|
|
891
1052
|
if (!Util.isFalsy(val))
|
|
892
1053
|
result.push(val);
|
|
@@ -907,17 +1068,32 @@ class PathToAttribValue extends Path {
|
|
|
907
1068
|
/**
|
|
908
1069
|
* @param funcAndArgs {?Array} The [func, ...args] array from the template, or null if func stands alone. */
|
|
909
1070
|
bindEvent(node, root, key, eventName, func, funcAndArgs, capture=false) {
|
|
910
|
-
|
|
911
|
-
throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${${func}}> because it's not a function.`);
|
|
1071
|
+
|
|
912
1072
|
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
|
|
1073
|
+
// Delegated path: a bubbling event (when the root's options allow it, the default)
|
|
1074
|
+
// stores its handler directly on the node as a per-event-type Symbol expando, with no
|
|
1075
|
+
// EventBinding object and no addEventListener call. The root-level dispatcher reads
|
|
1076
|
+
// these expandos while walking up from the event target. Re-renders just overwrite
|
|
1077
|
+
// the property. this.delegatedKey is set by the PathToEvent constructor only for
|
|
1078
|
+
// delegatable event names, so this test also excludes non-bubbling events.
|
|
1079
|
+
if (capture === false && this.delegatedKey !== undefined) {
|
|
1080
|
+
let opt = this.parentNg.rootNg.renderOptions?.eventDelegation ?? true;
|
|
1081
|
+
let toDocument = opt === 'document';
|
|
1082
|
+
if (opt !== false && (opt === true || toDocument || opt.includes(eventName))) {
|
|
1083
|
+
let dk = this.delegatedKey;
|
|
1084
|
+
if (node[dk] === undefined) // First binding of this type on this node.
|
|
1085
|
+
ensureDelegatedDispatcher(root, eventName, toDocument);
|
|
1086
|
+
// Array-form bindings (onclick=${[fn, arg]}, the hot per-row case) store the
|
|
1087
|
+
// template's own [func, ...args] array; a plain function is stored bare.
|
|
1088
|
+
// Either way, nothing is allocated.
|
|
1089
|
+
node[dk] = funcAndArgs || func;
|
|
1090
|
+
node[delegatedRootKey] = root;
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
917
1094
|
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
// a plain function allocates a one-element array, which is rare (buttons, two-way).
|
|
1095
|
+
// Direct path: capture bindings, non-bubbling events, and eventDelegation:false.
|
|
1096
|
+
// Store the callable as a single [func, ...args] array.
|
|
921
1097
|
let args = funcAndArgs || [func];
|
|
922
1098
|
|
|
923
1099
|
// One stable EventBinding object per node+key is registered with addEventListener
|
|
@@ -927,7 +1103,7 @@ class PathToAttribValue extends Path {
|
|
|
927
1103
|
let nodeEvents = node[eventBindingsKey];
|
|
928
1104
|
if (nodeEvents === undefined) {
|
|
929
1105
|
let b = node[eventBindingsKey] = new EventBinding(root, node, key, args);
|
|
930
|
-
|
|
1106
|
+
node.addEventListener(eventName, b, capture);
|
|
931
1107
|
return;
|
|
932
1108
|
}
|
|
933
1109
|
|
|
@@ -945,7 +1121,7 @@ class PathToAttribValue extends Path {
|
|
|
945
1121
|
let map = node[eventBindingsKey] = {};
|
|
946
1122
|
map[nodeEvents.key] = nodeEvents;
|
|
947
1123
|
binding = map[key] = new EventBinding(root, node, key, args);
|
|
948
|
-
|
|
1124
|
+
node.addEventListener(eventName, binding, capture);
|
|
949
1125
|
return;
|
|
950
1126
|
}
|
|
951
1127
|
}
|
|
@@ -953,11 +1129,11 @@ class PathToAttribValue extends Path {
|
|
|
953
1129
|
binding = nodeEvents[key];
|
|
954
1130
|
if (!binding) {
|
|
955
1131
|
binding = nodeEvents[key] = new EventBinding(root, node, key, args);
|
|
956
|
-
|
|
1132
|
+
node.addEventListener(eventName, binding, capture);
|
|
957
1133
|
return;
|
|
958
1134
|
}
|
|
959
1135
|
}
|
|
960
|
-
binding.
|
|
1136
|
+
binding.rootEl = root;
|
|
961
1137
|
binding.args = args;
|
|
962
1138
|
}
|
|
963
1139
|
}
|
|
@@ -978,73 +1154,99 @@ function getEventBinding(node, key) {
|
|
|
978
1154
|
return b instanceof EventBinding ? (b.key === key ? b : undefined) : b[key];
|
|
979
1155
|
}
|
|
980
1156
|
|
|
981
|
-
/**
|
|
982
|
-
* Attach a new EventBinding either directly or through the root component's delegated
|
|
983
|
-
* dispatcher. The dispatcher lives on the root element (not the document) so a component
|
|
984
|
-
* still receives delegated events while detached from the document, and events stay scoped
|
|
985
|
-
* to the component that rendered them. */
|
|
986
|
-
function registerBinding(binding, node, eventName, capture, options, root) {
|
|
987
|
-
// Bubbling events are delegated by default: they skip addEventListener entirely, and one
|
|
988
|
-
// root-level dispatcher per event type finds bindings by walking up from the event target.
|
|
989
|
-
// eventDelegation:false opts out; an array delegates only the named events. Capture
|
|
990
|
-
// bindings and non-bubbling events always stay direct.
|
|
991
|
-
let delegate = false;
|
|
992
|
-
if (capture === false) {
|
|
993
|
-
let opt = options?.eventDelegation ?? true;
|
|
994
|
-
if (opt !== false && delegatableEvents.has(eventName))
|
|
995
|
-
delegate = opt === true || opt.includes(eventName);
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
if (delegate) {
|
|
999
|
-
binding.delegated = true;
|
|
1000
|
-
let types = root[delegatedTypesKey];
|
|
1001
|
-
if (types === undefined)
|
|
1002
|
-
types = root[delegatedTypesKey] = new Set();
|
|
1003
|
-
if (!types.has(eventName)) {
|
|
1004
|
-
types.add(eventName);
|
|
1005
|
-
root.addEventListener(eventName, delegatedDispatcher);
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
else
|
|
1009
|
-
node.addEventListener(eventName, binding, capture);
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
1157
|
// Bubbling events that one root-level listener can dispatch. Same set Solid.js delegates.
|
|
1013
1158
|
const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
|
|
1014
1159
|
'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
|
|
1015
1160
|
'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
|
|
1016
1161
|
|
|
1162
|
+
// One Symbol per delegated event type; nodes store their delegated handler under it.
|
|
1163
|
+
// Symbols (vs string expandos like Solid's $$click) can't collide with user properties.
|
|
1164
|
+
const delegatedKeys = {};
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Get the per-event-type Symbol key, or undefined for non-delegatable events.
|
|
1168
|
+
* Called once per PathToEvent construction, never per bind.
|
|
1169
|
+
* @param eventName {string}
|
|
1170
|
+
* @return {symbol|undefined} */
|
|
1171
|
+
function delegatedKeyFor(eventName) {
|
|
1172
|
+
if (!delegatableEvents.has(eventName))
|
|
1173
|
+
return undefined;
|
|
1174
|
+
return delegatedKeys[eventName] ??= Symbol('sol$' + eventName);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// The component root a node's delegated handlers run with as `this`.
|
|
1178
|
+
// Exported so NodeGroup.applyStamp()'s compiled stamp program can write it directly.
|
|
1179
|
+
const delegatedRootKey = Symbol('solariteDelegatedRoot');
|
|
1180
|
+
|
|
1017
1181
|
// Per-root-element Set of event types that already have a delegated dispatcher registered.
|
|
1018
1182
|
const delegatedTypesKey = Symbol('solariteDelegatedTypes');
|
|
1019
1183
|
|
|
1184
|
+
/**
|
|
1185
|
+
* Register the delegated dispatcher for eventName on root if it isn't already.
|
|
1186
|
+
* Shared by bindEvent()'s delegated branch and NodeGroup.applyStamp()'s stamp program.
|
|
1187
|
+
*
|
|
1188
|
+
* With andDocument (the eventDelegation:'document' render option), the dispatcher is also
|
|
1189
|
+
* registered on the document, once per event type: a bound node that gets re-parented
|
|
1190
|
+
* OUTSIDE its root (e.g. a toolbar a dock parks in its own chrome) bubbles past the root's
|
|
1191
|
+
* listener, and only a document-level listener can still reach its handler. The
|
|
1192
|
+
* delegatedDoneKey marker keeps the two dispatchers from double-running the same event.
|
|
1193
|
+
* @param root {HTMLElement}
|
|
1194
|
+
* @param eventName {string}
|
|
1195
|
+
* @param andDocument {boolean} */
|
|
1196
|
+
function ensureDelegatedDispatcher(root, eventName, andDocument=false) {
|
|
1197
|
+
let types = root[delegatedTypesKey];
|
|
1198
|
+
if (types === undefined)
|
|
1199
|
+
types = root[delegatedTypesKey] = new Set();
|
|
1200
|
+
if (!types.has(eventName)) {
|
|
1201
|
+
types.add(eventName);
|
|
1202
|
+
root.addEventListener(eventName, delegatedDispatcher);
|
|
1203
|
+
}
|
|
1204
|
+
if (andDocument) {
|
|
1205
|
+
let doc = root.ownerDocument ?? document;
|
|
1206
|
+
let docTypes = doc[delegatedTypesKey];
|
|
1207
|
+
if (docTypes === undefined)
|
|
1208
|
+
docTypes = doc[delegatedTypesKey] = new Set();
|
|
1209
|
+
if (!docTypes.has(eventName)) {
|
|
1210
|
+
docTypes.add(eventName);
|
|
1211
|
+
doc.addEventListener(eventName, delegatedDispatcher);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1020
1216
|
// Marks an event the innermost root dispatcher has already walked, so an outer root's
|
|
1021
1217
|
// listener (when components are nested) skips it instead of dispatching the bindings again.
|
|
1022
1218
|
const delegatedDoneKey = Symbol('solariteDelegated');
|
|
1023
1219
|
|
|
1024
1220
|
/**
|
|
1025
1221
|
* The per-root listener for each delegated event type. The first (innermost) root the
|
|
1026
|
-
* bubbling event reaches walks from the event target upward, invoking delegated
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
* bubbling. */
|
|
1222
|
+
* bubbling event reaches walks from the event target upward, invoking delegated handlers
|
|
1223
|
+
* stored on the nodes along the way; outer roots then see the done-marker and skip.
|
|
1224
|
+
* Each node carries the root its handlers run with as `this` (see delegatedRootKey), so
|
|
1225
|
+
* handlers in an outer component still run with the correct component. event.currentTarget
|
|
1226
|
+
* is patched to the node whose handler is running, and restored after. stopPropagation()
|
|
1227
|
+
* inside a handler ends the walk, mirroring native bubbling. */
|
|
1032
1228
|
function delegatedDispatcher(ev) {
|
|
1033
1229
|
if (ev[delegatedDoneKey])
|
|
1034
1230
|
return;
|
|
1035
1231
|
ev[delegatedDoneKey] = true;
|
|
1036
|
-
let
|
|
1232
|
+
let dk = delegatedKeys[ev.type];
|
|
1037
1233
|
let current = ev.target;
|
|
1038
1234
|
Object.defineProperty(ev, 'currentTarget', {configurable: true, get() { return current }});
|
|
1039
1235
|
while (current) {
|
|
1040
|
-
let
|
|
1041
|
-
if (
|
|
1042
|
-
let
|
|
1043
|
-
if (
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1236
|
+
let a = current[dk];
|
|
1237
|
+
if (a !== undefined) {
|
|
1238
|
+
let root = current[delegatedRootKey];
|
|
1239
|
+
if (typeof a === 'function')
|
|
1240
|
+
a.call(root, ev, current);
|
|
1241
|
+
else
|
|
1242
|
+
switch (a.length) {
|
|
1243
|
+
case 1: a[0].call(root, ev, current); break;
|
|
1244
|
+
case 2: a[0].call(root, a[1], ev, current); break;
|
|
1245
|
+
case 3: a[0].call(root, a[1], a[2], ev, current); break;
|
|
1246
|
+
default: a[0].call(root, ...a.slice(1), ev, current);
|
|
1247
|
+
}
|
|
1248
|
+
if (ev.cancelBubble)
|
|
1249
|
+
break;
|
|
1048
1250
|
}
|
|
1049
1251
|
current = current.parentNode;
|
|
1050
1252
|
}
|
|
@@ -1053,7 +1255,7 @@ function delegatedDispatcher(ev) {
|
|
|
1053
1255
|
|
|
1054
1256
|
class EventBinding {
|
|
1055
1257
|
constructor(root, node, key, args) {
|
|
1056
|
-
this.
|
|
1258
|
+
this.rootEl = root;
|
|
1057
1259
|
this.node = node;
|
|
1058
1260
|
this.key = key;
|
|
1059
1261
|
|
|
@@ -1067,23 +1269,29 @@ class EventBinding {
|
|
|
1067
1269
|
'handleEvent'(event) {
|
|
1068
1270
|
let a = this.args;
|
|
1069
1271
|
switch (a.length) {
|
|
1070
|
-
case 1: return a[0].call(this.
|
|
1071
|
-
case 2: return a[0].call(this.
|
|
1072
|
-
case 3: return a[0].call(this.
|
|
1272
|
+
case 1: return a[0].call(this.rootEl, event, this.node);
|
|
1273
|
+
case 2: return a[0].call(this.rootEl, a[1], event, this.node);
|
|
1274
|
+
case 3: return a[0].call(this.rootEl, a[1], a[2], event, this.node);
|
|
1073
1275
|
}
|
|
1074
|
-
return a[0].call(this.
|
|
1276
|
+
return a[0].call(this.rootEl, ...a.slice(1), event, this.node);
|
|
1075
1277
|
}
|
|
1076
1278
|
}
|
|
1077
1279
|
|
|
1078
1280
|
// TODO: Merge this into PathToAttribValue?
|
|
1079
1281
|
class PathToEvent extends PathToAttribValue {
|
|
1080
1282
|
|
|
1081
|
-
/** @type {string} The
|
|
1283
|
+
/** @type {string} The attribName without the "on" prefix. */
|
|
1082
1284
|
eventName;
|
|
1083
1285
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1286
|
+
/** @type {symbol|undefined} Expando key nodes store this event's delegated handler under.
|
|
1287
|
+
* Undefined for non-delegatable (non-bubbling) events; bindEvent() then binds directly. */
|
|
1288
|
+
delegatedKey;
|
|
1289
|
+
|
|
1290
|
+
constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
|
|
1291
|
+
super(null, nodeMarker, attribName, attrValue);
|
|
1292
|
+
this.skipIfSame = true;
|
|
1293
|
+
this.eventName = attribName ? attribName.slice(2) : null;
|
|
1294
|
+
this.delegatedKey = this.eventName !== null ? delegatedKeyFor(this.eventName) : undefined;
|
|
1087
1295
|
}
|
|
1088
1296
|
|
|
1089
1297
|
/**
|
|
@@ -1093,14 +1301,14 @@ class PathToEvent extends PathToAttribValue {
|
|
|
1093
1301
|
* onclick=${[this, 'doSomething', 'meow']}
|
|
1094
1302
|
*
|
|
1095
1303
|
* @param exprs {Expr[]} Only the first is used.*/
|
|
1096
|
-
|
|
1304
|
+
applyAll(exprs) {
|
|
1097
1305
|
|
|
1098
1306
|
|
|
1099
1307
|
// Tested by Solariate.events.classicWithExpr
|
|
1100
1308
|
// We have expressions within a string attribute value that's not a Solarite event. E.g.
|
|
1101
1309
|
// <div onclick="alert(${1});"
|
|
1102
1310
|
if (this.attrValue?.length > 1) {
|
|
1103
|
-
super.
|
|
1311
|
+
super.applyAll(exprs);
|
|
1104
1312
|
return;
|
|
1105
1313
|
}
|
|
1106
1314
|
|
|
@@ -1112,14 +1320,14 @@ class PathToEvent extends PathToAttribValue {
|
|
|
1112
1320
|
applySingle(expr) {
|
|
1113
1321
|
// Expressions within a string attribute value that's not a Solarite event.
|
|
1114
1322
|
if (this.attrValue?.length > 1)
|
|
1115
|
-
return super.
|
|
1323
|
+
return super.applyAll([expr]);
|
|
1116
1324
|
|
|
1117
1325
|
// Don't bind events to component placeholders.
|
|
1118
1326
|
// PathToComponent will do the binding later when it instantiates the component.
|
|
1119
1327
|
if (this.isComponentAttrib && this.nodeMarker.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
|
|
1120
1328
|
return;
|
|
1121
1329
|
|
|
1122
|
-
let root = this.parentNg.rootNg.
|
|
1330
|
+
let root = this.parentNg.rootNg.rootEl;
|
|
1123
1331
|
|
|
1124
1332
|
|
|
1125
1333
|
|
|
@@ -1137,7 +1345,7 @@ class PathToEvent extends PathToAttribValue {
|
|
|
1137
1345
|
expr = null;
|
|
1138
1346
|
}
|
|
1139
1347
|
else
|
|
1140
|
-
throw new Error(`
|
|
1348
|
+
throw new Error(`Solarite: ${this.attribName}=\${...} is not a function.`);
|
|
1141
1349
|
|
|
1142
1350
|
this.bindEvent(node, root, eventName, eventName, func, expr);
|
|
1143
1351
|
}
|
|
@@ -1260,13 +1468,10 @@ function jsxToTemplate(tag, props, children=[], key=undefined) {
|
|
|
1260
1468
|
|
|
1261
1469
|
// 2a. Custom element class => emit <tag-name ...props>children</tag-name>; PathToComponent
|
|
1262
1470
|
// instantiates it exactly like a tagged-template component.
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
tagName += '-element';
|
|
1268
|
-
return buildIntrinsic(tagName, props, children, key);
|
|
1269
|
-
}
|
|
1471
|
+
// defineClass() hands back the name it registered, or the name the class was already
|
|
1472
|
+
// registered under, so we never have to guess it a second time.
|
|
1473
|
+
if (tag.prototype instanceof HTMLElement)
|
|
1474
|
+
return buildIntrinsic(Util.defineClass(tag), props, children, key);
|
|
1270
1475
|
|
|
1271
1476
|
// 2b. Plain function component: call it with props (+ children) and expect a Template back.
|
|
1272
1477
|
let p = {};
|
|
@@ -1344,22 +1549,21 @@ class PathToAttribs extends Path {
|
|
|
1344
1549
|
* @type {Set<string>} Used for type=AttribType.Multiple to remember the attributes that were added. */
|
|
1345
1550
|
attrNames;
|
|
1346
1551
|
|
|
1347
|
-
/** @type {
|
|
1348
|
-
|
|
1552
|
+
/** @type {PathToEvent|PathToAttribValue|undefined} Cached sub-path for the JSX
|
|
1553
|
+
* whole-attribute fast path; see applyJsxAttr(). Declared so the first assignment
|
|
1554
|
+
* doesn't transition the hidden class. */
|
|
1555
|
+
jsxSub;
|
|
1556
|
+
|
|
1557
|
+
/** @type {?string} The attribute name jsxSub was built for. */
|
|
1558
|
+
jsxSubName;
|
|
1349
1559
|
|
|
1350
1560
|
constructor(nodeBefore, nodeMarker) {
|
|
1351
|
-
|
|
1352
|
-
|
|
1561
|
+
// nodeBefore is discarded: an attribute path has no nodes of its own. The marker goes
|
|
1562
|
+
// straight through the base constructor rather than being stored a second time after it.
|
|
1563
|
+
super(null, nodeMarker);
|
|
1353
1564
|
this.attrNames = new Set();
|
|
1354
1565
|
}
|
|
1355
1566
|
|
|
1356
|
-
/**
|
|
1357
|
-
* @param exprs {Expr[][]} Only the first is used. */
|
|
1358
|
-
apply(exprs) {
|
|
1359
|
-
|
|
1360
|
-
this.applySingle(exprs[0]);
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
1567
|
/**
|
|
1364
1568
|
* @param expr {Expr} */
|
|
1365
1569
|
applySingle(expr) {
|
|
@@ -1438,17 +1642,13 @@ class PathToAttribs extends Path {
|
|
|
1438
1642
|
value = styleToCss(value);
|
|
1439
1643
|
sub.applySingle(value);
|
|
1440
1644
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
getExpressionCount() { return 1 }
|
|
1444
|
-
getValue(exprs) { return exprs[0]; }
|
|
1445
1645
|
}
|
|
1446
1646
|
|
|
1447
1647
|
/**
|
|
1448
1648
|
* Maps a string key to multiple values.
|
|
1449
1649
|
* Values are stored in arrays because pushing them is much faster than Set operations,
|
|
1450
1650
|
* and deleteAny() needs no iterator allocation.
|
|
1451
|
-
* deleteAny() returns values first-in-first-out by advancing a head index (array.
|
|
1651
|
+
* deleteAny() returns values first-in-first-out by advancing a head index (array.hd)
|
|
1452
1652
|
* instead of calling shift(), which would be O(n). */
|
|
1453
1653
|
class MultiValueMap {
|
|
1454
1654
|
|
|
@@ -1476,7 +1676,7 @@ class MultiValueMap {
|
|
|
1476
1676
|
let array = data[key];
|
|
1477
1677
|
if (!array)
|
|
1478
1678
|
data[key] = [value];
|
|
1479
|
-
else if (array.length - (array.
|
|
1679
|
+
else if (array.length - (array.hd || 0) < max)
|
|
1480
1680
|
array.push(value);
|
|
1481
1681
|
}
|
|
1482
1682
|
|
|
@@ -1490,20 +1690,75 @@ class MultiValueMap {
|
|
|
1490
1690
|
if (!array) // slower than pre-check.
|
|
1491
1691
|
return undefined;
|
|
1492
1692
|
|
|
1493
|
-
let head = array.
|
|
1693
|
+
let head = array.hd || 0;
|
|
1494
1694
|
let result = array[head];
|
|
1495
1695
|
head++;
|
|
1496
1696
|
if (head >= array.length)
|
|
1497
1697
|
delete data[key];
|
|
1498
1698
|
else
|
|
1499
|
-
array.
|
|
1699
|
+
array.hd = head;
|
|
1500
1700
|
|
|
1501
1701
|
return result;
|
|
1502
1702
|
}
|
|
1503
1703
|
}
|
|
1504
1704
|
|
|
1705
|
+
/**
|
|
1706
|
+
* A list of items plus the function that builds one item's Template, as returned by h.map().
|
|
1707
|
+
*
|
|
1708
|
+
* Handing the reconciler the source items instead of an array of Templates is what makes
|
|
1709
|
+
* h.map() cheap on a long list: a row whose item is the same object it was built from needs
|
|
1710
|
+
* neither a Template built for it nor a cache lookup to find one, just an identity check
|
|
1711
|
+
* against the item the row already remembers. Rows that moved are recognized too — see
|
|
1712
|
+
* PathToNodes.applyMapped(), which follows a shifted list's offset and, failing that, matches
|
|
1713
|
+
* items against the Templates the previous render built.
|
|
1714
|
+
*/
|
|
1715
|
+
class MappedList {
|
|
1716
|
+
|
|
1717
|
+
/** @type {Array} */
|
|
1718
|
+
items;
|
|
1719
|
+
|
|
1720
|
+
/** @type {function(*):Template} */
|
|
1721
|
+
fn;
|
|
1722
|
+
|
|
1723
|
+
constructor(items, fn) {
|
|
1724
|
+
this.items = items;
|
|
1725
|
+
this.fn = fn;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* Yield the Templates, building each one as it goes, so that code written against the older
|
|
1730
|
+
* array-returning h.map() — spreading it, iterating it, passing it to Array.from — still
|
|
1731
|
+
* works. Doing so builds every row, which is exactly the work the reconciler skips when the
|
|
1732
|
+
* list is handed to it whole, so prefer putting an h.map() straight into a template. */
|
|
1733
|
+
*[Symbol.iterator]() {
|
|
1734
|
+
let items = this.items, fn = this.fn;
|
|
1735
|
+
for (let i=0; i<items.length; i++)
|
|
1736
|
+
yield fn(items[i]);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1505
1740
|
class PathToNodes extends Path {
|
|
1506
1741
|
|
|
1742
|
+
/** @type {boolean} True once any NodeGroup this path created needs a visit even when its
|
|
1743
|
+
* values are unchanged (it holds a component or a live HTML property). Those rows are the
|
|
1744
|
+
* reason the list scans exist, so their presence rules out applyMisses()' skip-the-scan
|
|
1745
|
+
* path. Sticky: it's never cleared, which can only cost a scan that wasn't needed. */
|
|
1746
|
+
anyNeedsRefresh = false;
|
|
1747
|
+
|
|
1748
|
+
/** @type {?Array} The h.map() items the previous render drew, one per NodeGroup and in the
|
|
1749
|
+
* same order, so an unchanged row is recognized by comparing two arrays rather than by
|
|
1750
|
+
* following a pointer into each NodeGroup. A thousand rows' NodeGroups are scattered over
|
|
1751
|
+
* a hundred kilobytes, so reading a field from each one costs a cache miss apiece; two flat
|
|
1752
|
+
* arrays walk in step. Null whenever the last render wasn't an h.map().
|
|
1753
|
+
* @type {?Array} */
|
|
1754
|
+
lastItems = null;
|
|
1755
|
+
|
|
1756
|
+
/** @type {boolean} True when the previous render's items contained raw DOM Nodes,
|
|
1757
|
+
* which routes applySingle() to the generic reconciler. Declared so the hot
|
|
1758
|
+
* `!this.itemsHaveNodes` check reads a real field instead of a missing property,
|
|
1759
|
+
* and so the first raw-Node render doesn't transition the hidden class. */
|
|
1760
|
+
itemsHaveNodes = false;
|
|
1761
|
+
|
|
1507
1762
|
/** @type {?NodeGroup[]} The NodeGroups created by this path's expression, in order.
|
|
1508
1763
|
* Lazily created; null when the path has only ever rendered a primitive (see textNode). */
|
|
1509
1764
|
nodeGroups = null;
|
|
@@ -1517,14 +1772,6 @@ class PathToNodes extends Path {
|
|
|
1517
1772
|
|
|
1518
1773
|
|
|
1519
1774
|
|
|
1520
|
-
/**
|
|
1521
|
-
* Nodes that have been used during the current render().
|
|
1522
|
-
* Used with getNodeGroup() and freeNodeGroups() on the generic path; the positional diff
|
|
1523
|
-
* tracks in-use NodeGroups in this.nodeGroups instead.
|
|
1524
|
-
* Lazily created since most paths never use it.
|
|
1525
|
-
* @type {?NodeGroup[]} */
|
|
1526
|
-
nodeGroupsRendered = null;
|
|
1527
|
-
|
|
1528
1775
|
/**
|
|
1529
1776
|
* Nodes that were added to the web component during the last render(), but are available to be used again.
|
|
1530
1777
|
* Used with getNodeGroup() and freeNodeGroups(), keyed by close key.
|
|
@@ -1542,16 +1789,6 @@ class PathToNodes extends Path {
|
|
|
1542
1789
|
super(nodeBefore, nodeMarker);
|
|
1543
1790
|
}
|
|
1544
1791
|
|
|
1545
|
-
/**
|
|
1546
|
-
* Insert/replace the nodes created by a single expression.
|
|
1547
|
-
* Called by applyExprs()
|
|
1548
|
-
* @param exprs {Expr[]} Only the first is used.
|
|
1549
|
-
* @return {Node[]} New Nodes created. */
|
|
1550
|
-
apply(exprs) {
|
|
1551
|
-
|
|
1552
|
-
this.applySingle(exprs[0]);
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
1792
|
/**
|
|
1556
1793
|
* Make the DOM between nodeBefore and nodeMarker match the value of expr.
|
|
1557
1794
|
* This is the main entry point for rendering an expression's nodes, chosen from three strategies:
|
|
@@ -1639,31 +1876,452 @@ class PathToNodes extends Path {
|
|
|
1639
1876
|
this.textNode = null;
|
|
1640
1877
|
}
|
|
1641
1878
|
|
|
1642
|
-
//
|
|
1879
|
+
// A selection binding only knows how to write an attribute, so catch it here rather than
|
|
1880
|
+
// letting it render as an empty string and leave the caller wondering where it went.
|
|
1881
|
+
if (expr instanceof SelectorRef)
|
|
1882
|
+
throw new Error('Solarite: a selector must own the whole attribute.');
|
|
1883
|
+
|
|
1884
|
+
// 1. h.map() hands over its source items and callback rather than built Templates, so a
|
|
1885
|
+
// row whose item is unchanged is recognized without building or looking up a Template.
|
|
1886
|
+
if (expr instanceof MappedList) {
|
|
1887
|
+
this.applyMapped(expr);
|
|
1888
|
+
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// Anything that isn't an h.map() leaves no items to recognize rows by next time.
|
|
1893
|
+
this.lastItems = null;
|
|
1894
|
+
|
|
1895
|
+
// 2. Flatten the expression to a list of Templates, strings and Nodes, evaluating functions along the way.
|
|
1896
|
+
// A flat array that is entirely Templates — the rows.map(...) shape that list renders
|
|
1897
|
+
// produce — is borrowed directly instead of copied. The borrow lasts only for the
|
|
1898
|
+
// rest of this synchronous call: applyDiff/applyKeyed/applyGeneric read the items and
|
|
1899
|
+
// retain only the NodeGroups (and each item's own Template) built from them, never the
|
|
1900
|
+
// items array itself, so no reference to the caller's array survives the render. Keep
|
|
1901
|
+
// that invariant — storing newItems on any long-lived object would pin the caller's
|
|
1902
|
+
// per-render array until the next render, moving its collection into a later frame.
|
|
1643
1903
|
/** @type {(Template|string|Node)[]} */
|
|
1644
|
-
let newItems =
|
|
1645
|
-
let hasNodesNow =
|
|
1904
|
+
let newItems = null;
|
|
1905
|
+
let hasNodesNow = false;
|
|
1906
|
+
if (Array.isArray(expr)) {
|
|
1907
|
+
let len = expr.length, i = 0;
|
|
1908
|
+
while (i < len && expr[i] instanceof Template)
|
|
1909
|
+
i++;
|
|
1910
|
+
if (i === len)
|
|
1911
|
+
newItems = expr; // Borrowed from the caller; read-only from here on.
|
|
1912
|
+
}
|
|
1913
|
+
if (newItems === null) {
|
|
1914
|
+
newItems = [];
|
|
1915
|
+
hasNodesNow = this.collectItems(expr, newItems, false);
|
|
1916
|
+
}
|
|
1646
1917
|
|
|
1647
|
-
//
|
|
1918
|
+
// 3. Raw Nodes in the items (now or on the previous render) can't be diffed positionally
|
|
1648
1919
|
// because this.nodeGroups only tracks NodeGroups. Use the generic path for those.
|
|
1649
1920
|
if (hasNodesNow || this.itemsHaveNodes) {
|
|
1650
1921
|
this.itemsHaveNodes = hasNodesNow;
|
|
1651
1922
|
this.applyGeneric(newItems);
|
|
1652
1923
|
}
|
|
1653
|
-
else
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1924
|
+
else
|
|
1925
|
+
this.diffItems(newItems);
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* Reconcile a flat list of Templates and strings against this path's NodeGroups.
|
|
1932
|
+
* Templates with a key=${} attribute diff by key so node identity follows the data.
|
|
1933
|
+
* An empty list also routes to applyKeyed when the previous render was keyed, so removed
|
|
1934
|
+
* keyed NodeGroups are discarded instead of pooled.
|
|
1935
|
+
* @param newItems {(Template|string)[]} */
|
|
1936
|
+
diffItems(newItems) {
|
|
1937
|
+
let first = newItems.length !== 0 ? newItems[0] : null;
|
|
1938
|
+
if (first !== null
|
|
1939
|
+
? (typeof first !== 'string' && (first.key !== undefined || Shell.get(first.html, first.svgMode).keyIndex >= 0))
|
|
1940
|
+
: (this.nodeGroups !== null && this.nodeGroups.length !== 0 && this.nodeGroups[0].key !== undefined))
|
|
1941
|
+
this.applyKeyed(newItems);
|
|
1942
|
+
else
|
|
1943
|
+
this.applyDiff(newItems);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
/**
|
|
1947
|
+
* Render an h.map() list.
|
|
1948
|
+
*
|
|
1949
|
+
* What makes this cheaper than reconciling an array of Templates is that a row still holding
|
|
1950
|
+
* the item it was built from needs no Template at all: it is recognized by one identity
|
|
1951
|
+
* check, with nothing built and nothing compared. When the list is the same length and only
|
|
1952
|
+
* a few rows changed, that is the whole render — see applyMisses(). Otherwise the walk
|
|
1953
|
+
* follows the offset a shifted list settles on, and finally consults a map from item to the
|
|
1954
|
+
* Template the previous render built, so rows that moved far are still reused.
|
|
1955
|
+
* @param mapped {MappedList} */
|
|
1956
|
+
applyMapped(mapped) {
|
|
1957
|
+
let items = mapped.items, fn = mapped.fn;
|
|
1958
|
+
let len = items.length;
|
|
1959
|
+
let oldNgs = this.nodeGroups;
|
|
1960
|
+
// Only rows this path drew from an h.map() last time can be recognized by their item;
|
|
1961
|
+
// anything else starts over.
|
|
1962
|
+
let lastItems = this.lastItems;
|
|
1963
|
+
let oldLen = oldNgs === null || lastItems === null || lastItems.length !== oldNgs.length
|
|
1964
|
+
? 0 : oldNgs.length;
|
|
1965
|
+
|
|
1966
|
+
// Patch path. When the list is the same length as last time, every row that still holds
|
|
1967
|
+
// the item it was built from is already final: it needs no Template, no comparison and no
|
|
1968
|
+
// visit. So find the positions that did change, build only those, and patch them. That
|
|
1969
|
+
// makes a selection or a partial update cost work proportional to the change instead of
|
|
1970
|
+
// to the length of the list. Rows that must be visited even when unchanged (components,
|
|
1971
|
+
// live HTML properties) rule it out, since revisiting them is what the full scan is for.
|
|
1972
|
+
let misses = null, missTemplates = null, missCount = 0;
|
|
1973
|
+
if (oldLen === len && len !== 0 && !this.anyNeedsRefresh && !this.itemsHaveNodes) {
|
|
1974
|
+
let tooMany = false;
|
|
1975
|
+
let cap = missProbeThreshold;
|
|
1976
|
+
|
|
1977
|
+
// First find WHICH positions changed, without building anything for them. A change
|
|
1978
|
+
// this path can't handle is then abandoned having cost only comparisons — building
|
|
1979
|
+
// as we went would throw away a Template for every row of, say, a reversed list,
|
|
1980
|
+
// which the general diff is about to reuse from the previous render.
|
|
1981
|
+
for (let i=0; i<len; i++) {
|
|
1982
|
+
if (lastItems[i] !== items[i]) {
|
|
1983
|
+
if (missCount === cap) {
|
|
1984
|
+
// Enough of the list has changed to ask what kind of change this is,
|
|
1985
|
+
// because the two kinds want opposite treatment. If the item at this
|
|
1986
|
+
// position is somewhere else in the old list, the rows were reordered,
|
|
1987
|
+
// and the general diff's item map will reuse their Templates instead of
|
|
1988
|
+
// rebuilding them — so stop here and let it. If the item is new, the
|
|
1989
|
+
// rows' contents changed, and there is nothing to reuse: keep going and
|
|
1990
|
+
// patch them all, however many there are. The scan costs one pass over
|
|
1991
|
+
// the old rows, once, and only for a list that changed this much.
|
|
1992
|
+
if (itemIsElsewhere(lastItems, oldLen, items[i])) {
|
|
1993
|
+
tooMany = true;
|
|
1994
|
+
missCount = 0; // Nothing was built, so the general path has nothing to reuse.
|
|
1995
|
+
break;
|
|
1996
|
+
}
|
|
1997
|
+
cap = len; // Asked and answered; there is no second probe.
|
|
1998
|
+
}
|
|
1999
|
+
(misses ??= [])[missCount++] = i;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Now build them.
|
|
2004
|
+
if (!tooMany && missCount !== 0) {
|
|
2005
|
+
missTemplates = new Array(missCount);
|
|
2006
|
+
for (let k=0; k<missCount; k++) {
|
|
2007
|
+
let t = fn(items[misses[k]]);
|
|
2008
|
+
if (!(t instanceof Template) && typeof t !== 'string') { // A Node, an array, …
|
|
2009
|
+
tooMany = true;
|
|
2010
|
+
missCount = k; // Keep the ones already built; the rest are the caller's problem.
|
|
2011
|
+
break;
|
|
2012
|
+
}
|
|
2013
|
+
missTemplates[k] = t;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
if (!tooMany && (missCount === 0
|
|
2017
|
+
|| this.applyMisses(oldNgs, misses, missTemplates, missCount, len))) {
|
|
2018
|
+
for (let k=0; k<missCount; k++) {
|
|
2019
|
+
let j = misses[k];
|
|
2020
|
+
lastItems[j] = items[j];
|
|
2021
|
+
}
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// General path: build the whole list of Templates and hand it to the reconciler.
|
|
2027
|
+
let newItems = new Array(len);
|
|
2028
|
+
let built = missCount !== 0 ? misses : null, b = 0;
|
|
2029
|
+
let itemMap = null, noItemMap = false;
|
|
2030
|
+
const indexOfItem = item => {
|
|
2031
|
+
if (noItemMap)
|
|
2032
|
+
return -1;
|
|
2033
|
+
if (itemMap === null) {
|
|
2034
|
+
// One scan before paying for a map: if this item is nowhere in the old rows, the
|
|
2035
|
+
// list's contents changed rather than moved, so there is nothing to look up and
|
|
2036
|
+
// every later miss can go straight to the callback. A scan is cheaper than a map
|
|
2037
|
+
// of every row, and this is the common shape — rows replaced in place.
|
|
2038
|
+
if (!itemIsElsewhere(lastItems, oldLen, item)) {
|
|
2039
|
+
noItemMap = true;
|
|
2040
|
+
return -1;
|
|
2041
|
+
}
|
|
2042
|
+
itemMap = new Map();
|
|
2043
|
+
for (let k=0; k<oldLen; k++)
|
|
2044
|
+
itemMap.set(lastItems[k], k);
|
|
2045
|
+
}
|
|
2046
|
+
let k = itemMap.get(item);
|
|
2047
|
+
return k === undefined ? -1 : k;
|
|
2048
|
+
};
|
|
2049
|
+
// Walk the two lists together. A row is recognized by the item it was built from, at the
|
|
2050
|
+
// offset the walk has settled on: after an insertion or a removal every later row sits a
|
|
2051
|
+
// fixed distance from where it was, and following that keeps recognizing them instead of
|
|
2052
|
+
// treating the whole tail as changed. The short search that re-establishes the offset
|
|
2053
|
+
// only runs while the walk is still in step, so a list of genuinely new rows (an append,
|
|
2054
|
+
// a replace-all) gives up after one miss rather than searching for every row. Failing
|
|
2055
|
+
// all that, a map from item to the Template the previous render built for it catches
|
|
2056
|
+
// rows that moved far — a sort, a shuffle. It's built on demand, from the rows this
|
|
2057
|
+
// path already holds: a persistent per-item cache would instead pay a write for every
|
|
2058
|
+
// row of every list ever created, which is most of the work of building a list from
|
|
2059
|
+
// scratch, and would hold each Template alive for as long as the caller holds the item.
|
|
2060
|
+
if (oldLen !== 0) {
|
|
2061
|
+
let delta = 0, inSync = true;
|
|
2062
|
+
for (let i=0; i<len; i++) {
|
|
2063
|
+
let item = items[i];
|
|
2064
|
+
let j = i + delta;
|
|
2065
|
+
let inRange = j >= 0 && j < oldLen;
|
|
2066
|
+
if (inRange && lastItems[j] === item) {
|
|
2067
|
+
newItems[i] = oldNgs[j].template;
|
|
2068
|
+
inSync = true;
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// This position was already found to have changed, and its Template built, by the
|
|
2073
|
+
// patch scan above. That only happens for a same-length list, where the offset
|
|
2074
|
+
// stays zero, so there's no search to redo here.
|
|
2075
|
+
if (built !== null && b < missCount && built[b] === i) {
|
|
2076
|
+
newItems[i] = missTemplates[b++];
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
if (inSync) {
|
|
2081
|
+
let found = -1;
|
|
2082
|
+
for (let d=1; d<=shiftSearchDistance; d++) {
|
|
2083
|
+
let after = j + d, before = j - d;
|
|
2084
|
+
if (after < oldLen && lastItems[after] === item) {
|
|
2085
|
+
found = after;
|
|
2086
|
+
break;
|
|
2087
|
+
}
|
|
2088
|
+
if (before >= 0 && lastItems[before] === item) {
|
|
2089
|
+
found = before;
|
|
2090
|
+
break;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
if (found >= 0) {
|
|
2094
|
+
delta = found - i;
|
|
2095
|
+
newItems[i] = oldNgs[found].template;
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// The item isn't in the old list at all, but the old row standing here
|
|
2100
|
+
// belongs to an item a little further along: rows were INSERTED here. Build
|
|
2101
|
+
// this one and shift the offset, so the rest of the list is still recognized.
|
|
2102
|
+
// Without this, prepending one row to a long list would look like a change to
|
|
2103
|
+
// every row in it. Only worth asking when the list actually grew.
|
|
2104
|
+
if (inRange && len > oldLen)
|
|
2105
|
+
for (let d=1; d<=insertSearchDistance && i+d<len; d++)
|
|
2106
|
+
if (items[i+d] === lastItems[j]) {
|
|
2107
|
+
newItems[i] = fn(item);
|
|
2108
|
+
delta--;
|
|
2109
|
+
found = -2; // Handled; skip the fallbacks below.
|
|
2110
|
+
break;
|
|
2111
|
+
}
|
|
2112
|
+
if (found === -2)
|
|
2113
|
+
continue;
|
|
2114
|
+
|
|
2115
|
+
inSync = false;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// Past the end of the old list there is nothing left to match, so appended rows
|
|
2119
|
+
// go straight to the callback instead of paying for a lookup that must miss.
|
|
2120
|
+
if (j < oldLen) {
|
|
2121
|
+
let k = indexOfItem(item);
|
|
2122
|
+
if (k >= 0) {
|
|
2123
|
+
newItems[i] = oldNgs[k].template;
|
|
2124
|
+
delta = k - i; // Back in step; the rest of the list can walk positionally again.
|
|
2125
|
+
inSync = true;
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
newItems[i] = fn(item);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
else
|
|
2134
|
+
for (let i=0; i<len; i++)
|
|
2135
|
+
newItems[i] = fn(items[i]);
|
|
2136
|
+
|
|
2137
|
+
// A callback that returns something other than a Template or a string (a raw Node, an
|
|
2138
|
+
// array, a nested list) can't be diffed positionally; flatten it the general way.
|
|
2139
|
+
let first = len !== 0 ? newItems[0] : null;
|
|
2140
|
+
if (first !== null && !(first instanceof Template) && typeof first !== 'string') {
|
|
2141
|
+
let flat = [];
|
|
2142
|
+
let hasNodesNow = this.collectItems(newItems, flat, false);
|
|
2143
|
+
if (hasNodesNow || this.itemsHaveNodes) {
|
|
2144
|
+
this.itemsHaveNodes = hasNodesNow;
|
|
2145
|
+
this.applyGeneric(flat);
|
|
2146
|
+
}
|
|
1662
2147
|
else
|
|
1663
|
-
this.
|
|
2148
|
+
this.diffItems(flat);
|
|
2149
|
+
return;
|
|
1664
2150
|
}
|
|
1665
2151
|
|
|
1666
|
-
|
|
2152
|
+
if (this.itemsHaveNodes) {
|
|
2153
|
+
this.itemsHaveNodes = false;
|
|
2154
|
+
this.applyGeneric(newItems);
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
this.diffItems(newItems);
|
|
2159
|
+
|
|
2160
|
+
// Remember which item drew each row, so the next render can match them by identity.
|
|
2161
|
+
// The reconciler leaves nodeGroups aligned with newItems, and therefore with items.
|
|
2162
|
+
// The caller's array is copied rather than kept, since the caller mutates it in place.
|
|
2163
|
+
let li = this.lastItems;
|
|
2164
|
+
if (li === null || li.length !== len)
|
|
2165
|
+
li = this.lastItems = new Array(len);
|
|
2166
|
+
for (let j=0; j<len; j++)
|
|
2167
|
+
li[j] = items[j];
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Patch only the positions an h.map() render changed, leaving every other row alone.
|
|
2172
|
+
*
|
|
2173
|
+
* Every unchanged position already holds the NodeGroup built from that exact item, so it
|
|
2174
|
+
* needs no visit at all; only the changed positions can require a rewrite, a move, or a new
|
|
2175
|
+
* row. Changed positions are handled in two steps, the same shape as the general keyed
|
|
2176
|
+
* diff's small-reorder path: first the ones that kept their key (a row whose data changed
|
|
2177
|
+
* in place), then the leftovers are cross-matched against each other by key so a swap or a
|
|
2178
|
+
* short shuffle moves the fewest node ranges.
|
|
2179
|
+
*
|
|
2180
|
+
* @param ngs {NodeGroup[]} This path's NodeGroups, patched in place.
|
|
2181
|
+
* @param misses {int[]} Positions whose item changed, ascending.
|
|
2182
|
+
* @param templates {(Template|string)[]} The new Template for each of those positions.
|
|
2183
|
+
* @param missCount {int}
|
|
2184
|
+
* @param len {int} Length of the list, for anchoring the last position.
|
|
2185
|
+
* @return {boolean} False when the change doesn't fit this path and the caller must run
|
|
2186
|
+
* the general diff instead; nothing has been modified in that case. */
|
|
2187
|
+
applyMisses(ngs, misses, templates, missCount, len) {
|
|
2188
|
+
|
|
2189
|
+
// Only a keyed list can move rows around safely. An unkeyed one can still be rewritten
|
|
2190
|
+
// in place, which is what the positional diff would do for it anyway.
|
|
2191
|
+
let keyed = ngs[0].key !== undefined;
|
|
2192
|
+
|
|
2193
|
+
// 1. Classify the changed positions without touching anything, so that a change too big
|
|
2194
|
+
// for this path can still be handed to the general diff with nothing half-applied.
|
|
2195
|
+
// A row that kept its key is rewritten where it stands; the rest have to be matched
|
|
2196
|
+
// against each other, and past a handful of those the general diff's map-and-LIS
|
|
2197
|
+
// approach is the better tool.
|
|
2198
|
+
let displaced = null, dCount = 0;
|
|
2199
|
+
for (let k=0; k<missCount; k++) {
|
|
2200
|
+
let ng = ngs[misses[k]], t = templates[k];
|
|
2201
|
+
if (typeof t === 'string' || !itemClose(ng, t) || (keyed && ng.key !== keyOf(t))) {
|
|
2202
|
+
if (!keyed || dCount === maxDisplacedMisses)
|
|
2203
|
+
return false;
|
|
2204
|
+
(displaced ??= [])[dCount++] = k;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// 2. Rewrite the rows that kept their key. displaced holds indexes into misses in
|
|
2209
|
+
// ascending order, so one pointer walks past them.
|
|
2210
|
+
for (let k=0, d=0; k<missCount; k++) {
|
|
2211
|
+
if (d < dCount && displaced[d] === k) {
|
|
2212
|
+
d++;
|
|
2213
|
+
continue;
|
|
2214
|
+
}
|
|
2215
|
+
let ng = ngs[misses[k]], t = templates[k];
|
|
2216
|
+
if (itemSame(ng, t))
|
|
2217
|
+
this.refreshSameItem(ng, t);
|
|
2218
|
+
else
|
|
2219
|
+
this.rewriteNodeGroup(ng, t);
|
|
2220
|
+
}
|
|
2221
|
+
if (dCount === 0)
|
|
2222
|
+
return true;
|
|
2223
|
+
|
|
2224
|
+
// 3. Hand the displaced rows to the shared placer. displaced holds indexes into misses
|
|
2225
|
+
// and templates, so misses is what maps a row to its position in the list.
|
|
2226
|
+
let wholeParent = this.wholeParent;
|
|
2227
|
+
this.placeDisplaced(displaced, misses, ngs, templates, ngs, len,
|
|
2228
|
+
wholeParent ? null : this.nodeMarker,
|
|
2229
|
+
wholeParent ? this.nodeMarker : this.nodeMarker.parentNode);
|
|
2230
|
+
|
|
2231
|
+
// 4. Node membership or order changed, so invalidate caches.
|
|
2232
|
+
if (!this.parentNg.firstApply) {
|
|
2233
|
+
this.nodesCache = null;
|
|
2234
|
+
if (this.parentNg.parentPath)
|
|
2235
|
+
this.parentNg.parentPath.clearNodesCache();
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
// Keep state used by the generic path from going stale.
|
|
2239
|
+
if (this.nodeGroupsAttachedAvailable)
|
|
2240
|
+
this.nodeGroupsAttachedAvailable = null;
|
|
2241
|
+
return true;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* Settle a handful of rows that moved, appeared or vanished within one window of a list.
|
|
2246
|
+
*
|
|
2247
|
+
* Both small-reorder paths — the h.map() patch in applyMisses and the equal-length window in
|
|
2248
|
+
* applyKeyed — reach the same point: a few positions whose old NodeGroup no longer belongs
|
|
2249
|
+
* where it stands, everything around them already correct. Since every candidate came from
|
|
2250
|
+
* this same window, a swap, a dragged row or a short shuffle finds its partners inside it, so
|
|
2251
|
+
* the rows are cross-matched against each other by key rather than through the general
|
|
2252
|
+
* diff's key map and longest-increasing-subsequence machinery.
|
|
2253
|
+
*
|
|
2254
|
+
* rows holds ascending indexes into items, which is the array each caller already has; when
|
|
2255
|
+
* those indexes are not themselves list positions, positions maps them across. Doing the
|
|
2256
|
+
* indirection here rather than compacting it away in the caller keeps this off the allocation
|
|
2257
|
+
* path: neither caller builds an array it wasn't building already. rows.length is small by
|
|
2258
|
+
* construction (at most maxDisplacedMisses), which is what makes the O(n²) cross-match
|
|
2259
|
+
* cheaper than building a map.
|
|
2260
|
+
*
|
|
2261
|
+
* @param rows {int[]} Ascending indexes of the rows to settle.
|
|
2262
|
+
* @param positions {int[]|null} Maps a row index to its list position, or null when the row
|
|
2263
|
+
* indexes are already positions.
|
|
2264
|
+
* @param oldNgs {NodeGroup[]} Where each position's outgoing NodeGroup is read from.
|
|
2265
|
+
* @param items {(Template|string)[]} The new items, indexed by row index.
|
|
2266
|
+
* @param outNgs {NodeGroup[]} Receives the NodeGroup that ends up at each position. May be
|
|
2267
|
+
* the same array as oldNgs; the outgoing groups are snapshotted before anything is written.
|
|
2268
|
+
* @param boundary {int} First position past this window, where the anchor stops being
|
|
2269
|
+
* outNgs[p+1] and becomes tailAnchor.
|
|
2270
|
+
* @param tailAnchor {Node|null} Anchor for a row placed at boundary-1.
|
|
2271
|
+
* @param parent {Node} Where the rows' nodes live. */
|
|
2272
|
+
placeDisplaced(rows, positions, oldNgs, items, outNgs, boundary, tailAnchor, parent) {
|
|
2273
|
+
let count = rows.length;
|
|
2274
|
+
|
|
2275
|
+
// 1. Cross-match the rows against each other by key. A claimed NodeGroup is nulled out
|
|
2276
|
+
// of the snapshot so it can't be claimed twice.
|
|
2277
|
+
let free = new Array(count);
|
|
2278
|
+
for (let b=0; b<count; b++) {
|
|
2279
|
+
let i = rows[b];
|
|
2280
|
+
free[b] = oldNgs[positions === null ? i : positions[i]];
|
|
2281
|
+
}
|
|
2282
|
+
let placed = new Array(count);
|
|
2283
|
+
for (let a=0; a<count; a++) {
|
|
2284
|
+
let t = items[rows[a]];
|
|
2285
|
+
let key = keyOf(t);
|
|
2286
|
+
if (key !== undefined)
|
|
2287
|
+
for (let b=0; b<count; b++) {
|
|
2288
|
+
let ng = free[b];
|
|
2289
|
+
if (ng !== null && ng.key === key && itemClose(ng, t)) {
|
|
2290
|
+
free[b] = null;
|
|
2291
|
+
if (itemSame(ng, t))
|
|
2292
|
+
this.refreshSameItem(ng, t);
|
|
2293
|
+
else
|
|
2294
|
+
this.rewriteNodeGroup(ng, t);
|
|
2295
|
+
placed[a] = ng;
|
|
2296
|
+
break;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// 2. Discard the old rows nothing claimed. Keyed semantics require a new key to get new
|
|
2302
|
+
// nodes, so these are never pooled.
|
|
2303
|
+
for (let b=0; b<count; b++) {
|
|
2304
|
+
let ng = free[b];
|
|
2305
|
+
if (ng !== null) {
|
|
2306
|
+
if (ng.startNode !== ng.endNode)
|
|
2307
|
+
Util.saveOrphans(ng.getNodes());
|
|
2308
|
+
else
|
|
2309
|
+
ng.startNode.remove();
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// 3. Put the rows in place, right to left so each one's anchor is already final.
|
|
2314
|
+
for (let a=count-1; a>=0; a--) {
|
|
2315
|
+
let i = rows[a];
|
|
2316
|
+
let p = positions === null ? i : positions[i];
|
|
2317
|
+
let ng = placed[a];
|
|
2318
|
+
if (ng === undefined)
|
|
2319
|
+
ng = this.createNew(items[i]);
|
|
2320
|
+
outNgs[p] = ng;
|
|
2321
|
+
let anchor = p+1 < boundary ? outNgs[p+1].startNode : tailAnchor;
|
|
2322
|
+
if (ng.endNode.nextSibling !== anchor || ng.startNode.parentNode !== parent)
|
|
2323
|
+
insertNodesBefore(parent, ng, anchor);
|
|
2324
|
+
}
|
|
1667
2325
|
}
|
|
1668
2326
|
|
|
1669
2327
|
/**
|
|
@@ -1686,8 +2344,8 @@ class PathToNodes extends Path {
|
|
|
1686
2344
|
let ng = oldNgs[start], t = newItems[start];
|
|
1687
2345
|
if (!itemSame(ng, t))
|
|
1688
2346
|
break;
|
|
1689
|
-
if (ng.
|
|
1690
|
-
|
|
2347
|
+
if (ng.shell.needsRefresh)
|
|
2348
|
+
this.refreshSameItem(ng, t);
|
|
1691
2349
|
newNgs[start] = ng;
|
|
1692
2350
|
start++;
|
|
1693
2351
|
}
|
|
@@ -1697,8 +2355,8 @@ class PathToNodes extends Path {
|
|
|
1697
2355
|
let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
|
|
1698
2356
|
if (!itemSame(ng, t))
|
|
1699
2357
|
break;
|
|
1700
|
-
if (ng.
|
|
1701
|
-
|
|
2358
|
+
if (ng.shell.needsRefresh)
|
|
2359
|
+
this.refreshSameItem(ng, t);
|
|
1702
2360
|
newNgs[--newEnd] = ng;
|
|
1703
2361
|
oldEnd--;
|
|
1704
2362
|
}
|
|
@@ -1707,8 +2365,8 @@ class PathToNodes extends Path {
|
|
|
1707
2365
|
while (start < oldEnd && start < newEnd) {
|
|
1708
2366
|
let ng = oldNgs[start], t = newItems[start];
|
|
1709
2367
|
if (itemSame(ng, t)) { // Can happen between changed rows, e.g. partial updates.
|
|
1710
|
-
if (ng.
|
|
1711
|
-
|
|
2368
|
+
if (ng.shell.needsRefresh)
|
|
2369
|
+
this.refreshSameItem(ng, t);
|
|
1712
2370
|
}
|
|
1713
2371
|
else if (itemClose(ng, t))
|
|
1714
2372
|
this.rewriteNodeGroup(ng, t);
|
|
@@ -1745,34 +2403,18 @@ class PathToNodes extends Path {
|
|
|
1745
2403
|
}
|
|
1746
2404
|
}
|
|
1747
2405
|
|
|
1748
|
-
// 5. Insert leftover new items.
|
|
2406
|
+
// 5. Insert leftover new items directly. Each row is one native insert; a
|
|
2407
|
+
// batching DocumentFragment would double the insert count for no benefit,
|
|
2408
|
+
// since style/layout work is deferred until the next frame either way.
|
|
1749
2409
|
if (newRemain) {
|
|
1750
2410
|
let wholeParent = this.wholeParent;
|
|
1751
2411
|
let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
|
|
1752
2412
|
let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
|
|
1753
|
-
let target = parent, before = anchor;
|
|
1754
|
-
let fragment = null;
|
|
1755
|
-
if (newRemain > 1) { // Batch-insert through a fragment.
|
|
1756
|
-
fragment = Globals$1.doc.createDocumentFragment();
|
|
1757
|
-
target = fragment;
|
|
1758
|
-
before = null;
|
|
1759
|
-
}
|
|
1760
2413
|
for (let i=start; i<newEnd; i++) {
|
|
1761
2414
|
let ng = this.createOrReuse(newItems[i]);
|
|
1762
|
-
newNgs[i] = ng;
|
|
1763
|
-
|
|
1764
|
-
if (node === end) // Single-node NodeGroups are the common case in loops.
|
|
1765
|
-
target.insertBefore(node, before);
|
|
1766
|
-
else while (true) {
|
|
1767
|
-
let next = node.nextSibling;
|
|
1768
|
-
target.insertBefore(node, before);
|
|
1769
|
-
if (node === end)
|
|
1770
|
-
break;
|
|
1771
|
-
node = next;
|
|
1772
|
-
}
|
|
2415
|
+
newNgs[i] = ng;
|
|
2416
|
+
insertNodesBefore(parent, ng, anchor);
|
|
1773
2417
|
}
|
|
1774
|
-
if (fragment)
|
|
1775
|
-
parent.insertBefore(fragment, anchor);
|
|
1776
2418
|
}
|
|
1777
2419
|
|
|
1778
2420
|
// 6. Node membership changed, so invalidate caches.
|
|
@@ -1787,8 +2429,6 @@ class PathToNodes extends Path {
|
|
|
1787
2429
|
this.nodeGroups = newNgs;
|
|
1788
2430
|
|
|
1789
2431
|
// Keep state used by the generic path from going stale.
|
|
1790
|
-
if (this.nodeGroupsRendered)
|
|
1791
|
-
this.nodeGroupsRendered = null;
|
|
1792
2432
|
if (this.nodeGroupsAttachedAvailable)
|
|
1793
2433
|
this.nodeGroupsAttachedAvailable = null;
|
|
1794
2434
|
}
|
|
@@ -1807,18 +2447,6 @@ class PathToNodes extends Path {
|
|
|
1807
2447
|
let oldLen = oldNgs.length, newLen = newItems.length;
|
|
1808
2448
|
let newNgs = new Array(newLen);
|
|
1809
2449
|
|
|
1810
|
-
// Resolve an item's key, caching the html->keyIndex lookup for same-template lists.
|
|
1811
|
-
let keyHtml = null, keyIndex = -1;
|
|
1812
|
-
const keyOf = t => {
|
|
1813
|
-
if (t.key !== undefined) // JSX templates carry the key directly.
|
|
1814
|
-
return t.key;
|
|
1815
|
-
if (t.html !== keyHtml) {
|
|
1816
|
-
keyHtml = t.html;
|
|
1817
|
-
keyIndex = Shell.get(t.html, t.svgMode).keyIndex;
|
|
1818
|
-
}
|
|
1819
|
-
return keyIndex >= 0 ? t.exprs[keyIndex] : undefined;
|
|
1820
|
-
};
|
|
1821
|
-
|
|
1822
2450
|
|
|
1823
2451
|
|
|
1824
2452
|
let start = 0, oldEnd = oldLen, newEnd = newLen;
|
|
@@ -1828,15 +2456,13 @@ class PathToNodes extends Path {
|
|
|
1828
2456
|
let ng = oldNgs[start], t = newItems[start];
|
|
1829
2457
|
// An identical Template instance (h.map) implies an identical key, so skip key extraction.
|
|
1830
2458
|
if (ng.template === t) {
|
|
1831
|
-
if (ng.
|
|
1832
|
-
|
|
2459
|
+
if (ng.shell.needsRefresh)
|
|
2460
|
+
this.refreshSameItem(ng, t);
|
|
1833
2461
|
}
|
|
1834
2462
|
else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
|
|
1835
2463
|
break;
|
|
1836
|
-
else if (itemSame(ng, t))
|
|
1837
|
-
|
|
1838
|
-
ng.applyExprs(t.exprs, false);
|
|
1839
|
-
}
|
|
2464
|
+
else if (itemSame(ng, t))
|
|
2465
|
+
this.refreshSameItem(ng, t);
|
|
1840
2466
|
else
|
|
1841
2467
|
this.rewriteNodeGroup(ng, t);
|
|
1842
2468
|
newNgs[start] = ng;
|
|
@@ -1847,15 +2473,13 @@ class PathToNodes extends Path {
|
|
|
1847
2473
|
while (oldEnd > start && newEnd > start) {
|
|
1848
2474
|
let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
|
|
1849
2475
|
if (ng.template === t) {
|
|
1850
|
-
if (ng.
|
|
1851
|
-
|
|
2476
|
+
if (ng.shell.needsRefresh)
|
|
2477
|
+
this.refreshSameItem(ng, t);
|
|
1852
2478
|
}
|
|
1853
2479
|
else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
|
|
1854
2480
|
break;
|
|
1855
|
-
else if (itemSame(ng, t))
|
|
1856
|
-
|
|
1857
|
-
ng.applyExprs(t.exprs, false);
|
|
1858
|
-
}
|
|
2481
|
+
else if (itemSame(ng, t))
|
|
2482
|
+
this.refreshSameItem(ng, t);
|
|
1859
2483
|
else
|
|
1860
2484
|
this.rewriteNodeGroup(ng, t);
|
|
1861
2485
|
newNgs[--newEnd] = ng;
|
|
@@ -1867,6 +2491,57 @@ class PathToNodes extends Path {
|
|
|
1867
2491
|
let wholeParent = this.wholeParent;
|
|
1868
2492
|
let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
|
|
1869
2493
|
|
|
2494
|
+
// 3a. Equal-length windows: scan them aligned. Rows whose keys match positionally
|
|
2495
|
+
// are updated in place with no bookkeeping, and when at most 8 positions are
|
|
2496
|
+
// displaced (a swap, a dragged row, a small shuffle) they're cross-matched and
|
|
2497
|
+
// moved directly — no key map, no sources array, no LIS. A bigger shuffle falls
|
|
2498
|
+
// through to the general map phase; the in-place updates already done stay valid
|
|
2499
|
+
// there, since the map phase finds those rows already matching their new items.
|
|
2500
|
+
let fastHandled = false;
|
|
2501
|
+
if (oldRemain === newRemain) {
|
|
2502
|
+
let displaced = null;
|
|
2503
|
+
let ok = true;
|
|
2504
|
+
for (let i=start; i<newEnd; i++) {
|
|
2505
|
+
let ng = oldNgs[i], t = newItems[i];
|
|
2506
|
+
if (ng.template === t) {
|
|
2507
|
+
if (ng.shell.needsRefresh)
|
|
2508
|
+
this.refreshSameItem(ng, t);
|
|
2509
|
+
}
|
|
2510
|
+
else {
|
|
2511
|
+
let k = keyOf(t);
|
|
2512
|
+
if (k !== undefined && ng.key === k && itemClose(ng, t)) {
|
|
2513
|
+
if (itemSame(ng, t))
|
|
2514
|
+
this.refreshSameItem(ng, t);
|
|
2515
|
+
else
|
|
2516
|
+
this.rewriteNodeGroup(ng, t);
|
|
2517
|
+
}
|
|
2518
|
+
else {
|
|
2519
|
+
(displaced ??= []).push(i);
|
|
2520
|
+
if (displaced.length > 8) {
|
|
2521
|
+
ok = false;
|
|
2522
|
+
break;
|
|
2523
|
+
}
|
|
2524
|
+
continue; // newNgs[i] is filled during the placement pass below.
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
newNgs[i] = ng;
|
|
2528
|
+
}
|
|
2529
|
+
if (ok) {
|
|
2530
|
+
// The windows are the same length, so a displaced row's index is already its
|
|
2531
|
+
// position and no position map is needed. The tail anchor is the suffix row
|
|
2532
|
+
// just past this window, which placement never writes to — it only fills
|
|
2533
|
+
// positions below newEnd — so it is computed once here instead of on every
|
|
2534
|
+
// pass around the placement loop.
|
|
2535
|
+
if (displaced !== null)
|
|
2536
|
+
this.placeDisplaced(displaced, null, oldNgs, newItems, newNgs, newEnd,
|
|
2537
|
+
newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker),
|
|
2538
|
+
parent);
|
|
2539
|
+
fastHandled = true;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
if (!fastHandled) {
|
|
2544
|
+
|
|
1870
2545
|
// 3. Match the middle windows by key.
|
|
1871
2546
|
let kept = 0, moved = false;
|
|
1872
2547
|
let sources = null; // sources[i] = old index reused by new item start+i, or -1 to create fresh.
|
|
@@ -1892,10 +2567,8 @@ class PathToNodes extends Path {
|
|
|
1892
2567
|
moved = true;
|
|
1893
2568
|
else
|
|
1894
2569
|
lastNewIndex = newIndex;
|
|
1895
|
-
if (itemSame(ng, t))
|
|
1896
|
-
|
|
1897
|
-
ng.applyExprs(t.exprs, false);
|
|
1898
|
-
}
|
|
2570
|
+
if (itemSame(ng, t))
|
|
2571
|
+
this.refreshSameItem(ng, t);
|
|
1899
2572
|
else
|
|
1900
2573
|
this.rewriteNodeGroup(ng, t);
|
|
1901
2574
|
newNgs[newIndex] = ng;
|
|
@@ -1904,59 +2577,72 @@ class PathToNodes extends Path {
|
|
|
1904
2577
|
(removals ??= []).push(ng);
|
|
1905
2578
|
}
|
|
1906
2579
|
}
|
|
1907
|
-
else
|
|
1908
|
-
|
|
1909
|
-
|
|
2580
|
+
// else: the whole old window goes away. It isn't collected into an array here,
|
|
2581
|
+
// because the fast clear below usually takes every one of them at once and the
|
|
2582
|
+
// array would be built only to be thrown away.
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
// 3b. A large whole-parent list that is being fully replaced is emptied and refilled
|
|
2586
|
+
// with its parent detached, so the browser's connected-tree bookkeeping (child-change
|
|
2587
|
+
// notifications, tree-version bumps, MutationObserver interest walks, deferred
|
|
2588
|
+
// accessibility and style consumers) runs once at reattach instead of once per row
|
|
2589
|
+
// removed and once per row added. Detaching before the clear, rather than after it,
|
|
2590
|
+
// puts the removals on the cheap side of that line as well. The gates: the whole
|
|
2591
|
+
// region is being replaced, so nothing is kept and no focus can survive inside it;
|
|
2592
|
+
// the parent is a plain element, since detaching a custom element would fire its
|
|
2593
|
+
// disconnected/connectedCallback in the middle of a render and a subclass may run
|
|
2594
|
+
// arbitrary logic there; the parent is in the document, since the notification storm
|
|
2595
|
+
// only exists on a connected tree; and the list is long enough for the saving to beat
|
|
2596
|
+
// the fixed cost of the detour and the extra MutationObserver records it creates.
|
|
2597
|
+
let detachedFrom = null, reattachBefore = null;
|
|
2598
|
+
if (wholeParent && start === 0 && newEnd === newLen && kept === 0 && newRemain > 500
|
|
2599
|
+
&& parent.isConnected && parent.parentNode !== null
|
|
2600
|
+
&& parent.localName.indexOf('-') === -1 && !parent.hasAttribute('is')) {
|
|
2601
|
+
detachedFrom = parent.parentNode;
|
|
2602
|
+
reattachBefore = parent.nextSibling;
|
|
2603
|
+
parent.remove();
|
|
1910
2604
|
}
|
|
1911
2605
|
|
|
1912
2606
|
// 4. Remove unmatched old NodeGroups. They're discarded, never pooled,
|
|
1913
2607
|
// so a later render with new keys always creates new nodes.
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2608
|
+
let removeAll = oldRemain !== 0 && newRemain === 0;
|
|
2609
|
+
if (removals !== null || removeAll) {
|
|
2610
|
+
// Fast clear when nothing is kept anywhere; the whole region is removals. Trying
|
|
2611
|
+
// it first means a cleared list skips the two passes below entirely: those exist
|
|
2612
|
+
// to lift each group's nodes out one at a time, and emptying the parent has
|
|
2613
|
+
// already taken all of them.
|
|
2614
|
+
if (!(start === 0 && newEnd === newLen && kept === 0 && this.fastClear())) {
|
|
2615
|
+
if (removeAll)
|
|
2616
|
+
removals = oldNgs.slice(start, oldEnd);
|
|
2617
|
+
|
|
2618
|
+
// Materialize node caches of multi-node groups while attached, since detaching breaks sibling links.
|
|
2619
|
+
for (let ng of removals)
|
|
2620
|
+
if (ng.startNode !== ng.endNode)
|
|
2621
|
+
ng.getNodes();
|
|
1919
2622
|
|
|
1920
|
-
// Fast clear when nothing is kept anywhere; the whole region is removals.
|
|
1921
|
-
let cleared = start === 0 && newEnd === newLen && kept === 0 && this.fastClear();
|
|
1922
|
-
if (!cleared)
|
|
1923
2623
|
for (let ng of removals) {
|
|
1924
2624
|
if (ng.startNode !== ng.endNode)
|
|
1925
2625
|
Util.saveOrphans(ng.getNodes()); // Moves the nodes out of the DOM, into their own fragment.
|
|
1926
2626
|
else
|
|
1927
2627
|
ng.startNode.remove();
|
|
1928
2628
|
}
|
|
2629
|
+
}
|
|
1929
2630
|
}
|
|
1930
2631
|
|
|
1931
2632
|
// 5. Insert new NodeGroups and move kept ones.
|
|
1932
2633
|
if (newRemain) {
|
|
1933
2634
|
let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
|
|
1934
2635
|
|
|
1935
|
-
// 5a. Nothing kept in the middle:
|
|
2636
|
+
// 5a. Nothing kept in the middle: insert every new item directly.
|
|
2637
|
+
// Each row is one native insert; routing rows through a batching
|
|
2638
|
+
// DocumentFragment would double the insert count for no benefit, since
|
|
2639
|
+
// style/layout work is deferred until the next frame either way.
|
|
1936
2640
|
if (kept === 0) {
|
|
1937
|
-
let target = parent, before = anchor;
|
|
1938
|
-
let fragment = null;
|
|
1939
|
-
if (newRemain > 1) {
|
|
1940
|
-
fragment = Globals$1.doc.createDocumentFragment();
|
|
1941
|
-
target = fragment;
|
|
1942
|
-
before = null;
|
|
1943
|
-
}
|
|
1944
2641
|
for (let i=start; i<newEnd; i++) {
|
|
1945
2642
|
let ng = this.createNew(newItems[i]);
|
|
1946
2643
|
newNgs[i] = ng;
|
|
1947
|
-
|
|
1948
|
-
if (node === end)
|
|
1949
|
-
target.insertBefore(node, before);
|
|
1950
|
-
else while (true) {
|
|
1951
|
-
let next = node.nextSibling;
|
|
1952
|
-
target.insertBefore(node, before);
|
|
1953
|
-
if (node === end)
|
|
1954
|
-
break;
|
|
1955
|
-
node = next;
|
|
1956
|
-
}
|
|
2644
|
+
insertNodesBefore(parent, ng, anchor);
|
|
1957
2645
|
}
|
|
1958
|
-
if (fragment)
|
|
1959
|
-
parent.insertBefore(fragment, anchor);
|
|
1960
2646
|
}
|
|
1961
2647
|
|
|
1962
2648
|
// 5b. Mixed: iterate backwards so each item's anchor is already in place.
|
|
@@ -1983,6 +2669,11 @@ class PathToNodes extends Path {
|
|
|
1983
2669
|
}
|
|
1984
2670
|
}
|
|
1985
2671
|
|
|
2672
|
+
if (detachedFrom !== null)
|
|
2673
|
+
detachedFrom.insertBefore(parent, reattachBefore);
|
|
2674
|
+
|
|
2675
|
+
} // end if (!fastHandled)
|
|
2676
|
+
|
|
1986
2677
|
// 6. Node membership or order changed, so invalidate caches.
|
|
1987
2678
|
// During a NodeGroup's first applyExprs(), no ancestor caches can reference its nodes yet.
|
|
1988
2679
|
if (!this.parentNg.firstApply) {
|
|
@@ -1995,8 +2686,6 @@ class PathToNodes extends Path {
|
|
|
1995
2686
|
this.nodeGroups = newNgs;
|
|
1996
2687
|
|
|
1997
2688
|
// Keep state used by the generic path from going stale.
|
|
1998
|
-
if (this.nodeGroupsRendered)
|
|
1999
|
-
this.nodeGroupsRendered = null;
|
|
2000
2689
|
if (this.nodeGroupsAttachedAvailable)
|
|
2001
2690
|
this.nodeGroupsAttachedAvailable = null;
|
|
2002
2691
|
}
|
|
@@ -2010,11 +2699,30 @@ class PathToNodes extends Path {
|
|
|
2010
2699
|
if (typeof item === 'string')
|
|
2011
2700
|
return new NodeGroup(textTemplate(item), this); // Text NodeGroups have no paths to apply.
|
|
2012
2701
|
let ng = new NodeGroup(item, this);
|
|
2702
|
+
if (ng.shell.needsRefresh)
|
|
2703
|
+
this.anyNeedsRefresh = true;
|
|
2013
2704
|
if (item.exprs.length || (ng.paths && ng.paths.length))
|
|
2014
2705
|
ng.applyExprs(item.exprs);
|
|
2015
2706
|
return ng;
|
|
2016
2707
|
}
|
|
2017
2708
|
|
|
2709
|
+
/**
|
|
2710
|
+
* Refresh a NodeGroup whose new template has the SAME values as its current one.
|
|
2711
|
+
* Components still render so changes deeper in the tree can surface, and groups holding
|
|
2712
|
+
* live-HTML-property bindings (checked/value/selected) rewrite in place — a user's click
|
|
2713
|
+
* flips those DOM properties underneath the cached expression, so same values ≠ same DOM.
|
|
2714
|
+
* rewriteNodeGroup's per-path skip exempts exactly those paths; everything else is
|
|
2715
|
+
* compared and skipped as before, so this stays cheap.
|
|
2716
|
+
* @param ng {NodeGroup}
|
|
2717
|
+
* @param t {Template|string} */
|
|
2718
|
+
refreshSameItem(ng, t) {
|
|
2719
|
+
let shell = ng.shell;
|
|
2720
|
+
if (shell.hasComponentPaths)
|
|
2721
|
+
ng.applyExprs(t.exprs, false);
|
|
2722
|
+
else if (shell.hasLivePropPaths && shell.pathsSingleExpr && typeof t !== 'string')
|
|
2723
|
+
this.rewriteNodeGroup(ng, t);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2018
2726
|
/**
|
|
2019
2727
|
* Update an existing NodeGroup, created from the same html strings, with new values.
|
|
2020
2728
|
* @param ng {NodeGroup}
|
|
@@ -2028,15 +2736,21 @@ class PathToNodes extends Path {
|
|
|
2028
2736
|
else {
|
|
2029
2737
|
// When every path consumes exactly one expression, paths align 1:1 with exprs,
|
|
2030
2738
|
// so only the expressions that changed need to be applied.
|
|
2031
|
-
if (ng.pathsSingleExpr) {
|
|
2739
|
+
if (ng.shell.pathsSingleExpr) {
|
|
2032
2740
|
// Stamped groups (paths === null) rewrite through the shared stampers and stay
|
|
2033
2741
|
// path-less, unless a child-node expression stopped being primitive.
|
|
2034
2742
|
if (ng.paths !== null || !ng.rewriteStamp(item)) {
|
|
2035
2743
|
let oldExprs = ng.template.exprs, newExprs = item.exprs;
|
|
2036
2744
|
let paths = ng.paths ?? ng.materializePaths();
|
|
2037
|
-
for (let i = paths.length - 1; i >= 0; i--)
|
|
2038
|
-
|
|
2039
|
-
|
|
2745
|
+
for (let i = paths.length - 1; i >= 0; i--) {
|
|
2746
|
+
// Boolean live-HTML-property bindings are exempt from the unchanged-value
|
|
2747
|
+
// skip — a click flips the property underneath the cached expression;
|
|
2748
|
+
// applySingle() compares against the live node before writing.
|
|
2749
|
+
let oldExpr = oldExprs[i], newExpr = newExprs[i];
|
|
2750
|
+
if ((oldExpr !== newExpr && !exprSame(oldExpr, newExpr))
|
|
2751
|
+
|| (paths[i].isHtmlProperty && typeof newExpr === 'boolean'))
|
|
2752
|
+
paths[i].applySingle(newExpr);
|
|
2753
|
+
}
|
|
2040
2754
|
}
|
|
2041
2755
|
|
|
2042
2756
|
if (ng.styles)
|
|
@@ -2075,6 +2789,8 @@ class PathToNodes extends Path {
|
|
|
2075
2789
|
}
|
|
2076
2790
|
|
|
2077
2791
|
ng = new NodeGroup(item, this);
|
|
2792
|
+
if (ng.shell.needsRefresh)
|
|
2793
|
+
this.anyNeedsRefresh = true;
|
|
2078
2794
|
if (item.exprs.length || (ng.paths && ng.paths.length))
|
|
2079
2795
|
ng.applyExprs(item.exprs);
|
|
2080
2796
|
return ng;
|
|
@@ -2102,6 +2818,14 @@ class PathToNodes extends Path {
|
|
|
2102
2818
|
else if (typeof expr === 'function')
|
|
2103
2819
|
hasNodes = this.collectItems(expr(), items, hasNodes);
|
|
2104
2820
|
|
|
2821
|
+
// A MappedList nested inside an array or returned from a function can't use the
|
|
2822
|
+
// identity fast path, but it still renders; expand it through the per-item cache.
|
|
2823
|
+
else if (expr instanceof MappedList) {
|
|
2824
|
+
let subItems = expr.items, fn = expr.fn;
|
|
2825
|
+
for (let i=0; i<subItems.length; i++)
|
|
2826
|
+
items.push(fn(subItems[i]));
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2105
2829
|
else if (expr instanceof NodeList) {
|
|
2106
2830
|
for (let node of expr)
|
|
2107
2831
|
items.push(node);
|
|
@@ -2249,29 +2973,26 @@ class PathToNodes extends Path {
|
|
|
2249
2973
|
|| this.nodeGroupsDetachedAvailable?.deleteAny(closeKey);
|
|
2250
2974
|
|
|
2251
2975
|
if (result) {
|
|
2252
|
-
if (templatesSame(result.template, template))
|
|
2253
|
-
|
|
2254
|
-
if (result.hasComponentPaths)
|
|
2255
|
-
result.applyExprs(template.exprs, false);
|
|
2256
|
-
}
|
|
2976
|
+
if (templatesSame(result.template, template))
|
|
2977
|
+
this.refreshSameItem(result, template);
|
|
2257
2978
|
else
|
|
2258
2979
|
result.applyExprs(template.exprs);
|
|
2259
2980
|
result.template = template;
|
|
2260
2981
|
}
|
|
2261
2982
|
else {
|
|
2262
2983
|
result = new NodeGroup(template, this);
|
|
2984
|
+
if (result.shell.needsRefresh)
|
|
2985
|
+
this.anyNeedsRefresh = true;
|
|
2263
2986
|
result.applyExprs(template.exprs);
|
|
2264
2987
|
}
|
|
2265
2988
|
|
|
2266
|
-
(this.nodeGroupsRendered ??= []).push(result);
|
|
2267
|
-
|
|
2268
2989
|
|
|
2269
2990
|
return result;
|
|
2270
2991
|
}
|
|
2271
2992
|
|
|
2272
2993
|
|
|
2273
2994
|
/**
|
|
2274
|
-
* Move everything from this.
|
|
2995
|
+
* Move everything from this.nodeGroups to this.nodeGroupsAttached and nodeGroupsDetached.
|
|
2275
2996
|
* Called at the beginning of applyGeneric() so it can have NodeGroups to use.
|
|
2276
2997
|
* TODO: this could run as needed in getNodeGroup? */
|
|
2277
2998
|
freeNodeGroups() {
|
|
@@ -2281,7 +3002,7 @@ class PathToNodes extends Path {
|
|
|
2281
3002
|
let detached = (this.nodeGroupsDetachedAvailable ??= new MultiValueMap()).data;
|
|
2282
3003
|
for (let key in previouslyAttached) {
|
|
2283
3004
|
let src = previouslyAttached[key];
|
|
2284
|
-
let from = src.
|
|
3005
|
+
let from = src.hd || 0; // Skip entries already consumed by deleteAny().
|
|
2285
3006
|
let array = detached[key];
|
|
2286
3007
|
if (!array) {
|
|
2287
3008
|
array = detached[key] = from ? src.slice(from) : src;
|
|
@@ -2289,22 +3010,18 @@ class PathToNodes extends Path {
|
|
|
2289
3010
|
array.length = maxPooledPerKey;
|
|
2290
3011
|
}
|
|
2291
3012
|
else
|
|
2292
|
-
for (let i=from, max=maxPooledPerKey + (array.
|
|
3013
|
+
for (let i=from, max=maxPooledPerKey + (array.hd || 0); i<src.length && array.length < max; i++)
|
|
2293
3014
|
array.push(src[i]);
|
|
2294
3015
|
}
|
|
2295
3016
|
}
|
|
2296
3017
|
|
|
2297
|
-
//
|
|
2298
|
-
//
|
|
2299
|
-
// this.nodeGroups
|
|
2300
|
-
this.nodeGroupsAttachedAvailable = new MultiValueMap();
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
if (source)
|
|
2304
|
-
for (let ng of source)
|
|
3018
|
+
// Offer the NodeGroups the last render left in place for reuse. Every path that renders
|
|
3019
|
+
// NodeGroups — the positional diff, the keyed diff and applyGeneric alike — leaves them in
|
|
3020
|
+
// this.nodeGroups, so that one array is always the set still standing in the DOM.
|
|
3021
|
+
let nga = this.nodeGroupsAttachedAvailable = new MultiValueMap();
|
|
3022
|
+
if (this.nodeGroups)
|
|
3023
|
+
for (let ng of this.nodeGroups)
|
|
2305
3024
|
nga.add(ng.closeKey, ng);
|
|
2306
|
-
|
|
2307
|
-
this.nodeGroupsRendered = null;
|
|
2308
3025
|
}
|
|
2309
3026
|
|
|
2310
3027
|
|
|
@@ -2352,12 +3069,55 @@ class PathToNodes extends Path {
|
|
|
2352
3069
|
// Shared empty array for paths whose nodeGroups were never created. Never mutated.
|
|
2353
3070
|
const emptyNodeGroups = [];
|
|
2354
3071
|
|
|
3072
|
+
// How many changed h.map() positions applyMapped() collects before it stops to work out what
|
|
3073
|
+
// kind of change it is looking at (see the probe in applyMapped). Below this every ordinary
|
|
3074
|
+
// edit — a selection, a partial update — is handled without asking.
|
|
3075
|
+
const missProbeThreshold = 256;
|
|
3076
|
+
|
|
3077
|
+
// How many of those positions may need matching against each other before the general keyed
|
|
3078
|
+
// diff, with its key map and longest-increasing-subsequence, becomes the cheaper tool. The
|
|
3079
|
+
// cross-match here is quadratic, which only pays while the number of moved rows is small.
|
|
3080
|
+
const maxDisplacedMisses = 16;
|
|
3081
|
+
|
|
3082
|
+
// How far applyMapped() looks around a position to pick a shifted list's rows back up. One
|
|
3083
|
+
// insertion or removal moves everything by one, which the first step finds; a handful at once
|
|
3084
|
+
// still lands inside this window, and past it the item map takes over.
|
|
3085
|
+
const shiftSearchDistance = 4;
|
|
3086
|
+
|
|
3087
|
+
// How far ahead it looks to recognize a block of inserted rows, by finding the item that the
|
|
3088
|
+
// old row standing here now belongs to. Wider than the search above because inserting a page
|
|
3089
|
+
// of rows at once is ordinary, and because this search only runs while the walk is still in
|
|
3090
|
+
// step and stops it dead the first time it fails — so its worst case is one pass of this many
|
|
3091
|
+
// comparisons per render, against building a map of every row in the list.
|
|
3092
|
+
const insertSearchDistance = 64;
|
|
3093
|
+
|
|
2355
3094
|
// Most detached NodeGroups kept per close key. Bounds memory growth after very large
|
|
2356
3095
|
// lists are cleared while keeping pooled rows for every typical re-create pattern.
|
|
2357
3096
|
// Lowering this (e.g. to 1000) cuts retained memory ~7x after clearing a 10k-row list,
|
|
2358
3097
|
// but makes re-creating such a list ~2x slower since most rows are built fresh.
|
|
2359
3098
|
const maxPooledPerKey = 10000;
|
|
2360
3099
|
|
|
3100
|
+
|
|
3101
|
+
// Cache for keyOf(): list rows share one html array, so the Shell lookup that finds where the
|
|
3102
|
+
// key=${} expression sits happens once per list rather than once per row.
|
|
3103
|
+
let lastKeyHtml = null, lastKeyIndex = -1;
|
|
3104
|
+
|
|
3105
|
+
/**
|
|
3106
|
+
* The list key of an item, or undefined when it has none.
|
|
3107
|
+
* @param t {Template|string}
|
|
3108
|
+
* @return {*} */
|
|
3109
|
+
function keyOf(t) {
|
|
3110
|
+
if (typeof t === 'string')
|
|
3111
|
+
return undefined;
|
|
3112
|
+
if (t.key !== undefined) // JSX templates carry the key directly.
|
|
3113
|
+
return t.key;
|
|
3114
|
+
if (t.html !== lastKeyHtml) {
|
|
3115
|
+
lastKeyHtml = t.html;
|
|
3116
|
+
lastKeyIndex = Shell.get(t.html, t.svgMode).keyIndex;
|
|
3117
|
+
}
|
|
3118
|
+
return lastKeyIndex >= 0 ? t.exprs[lastKeyIndex] : undefined;
|
|
3119
|
+
}
|
|
3120
|
+
|
|
2361
3121
|
/**
|
|
2362
3122
|
* @param text {string}
|
|
2363
3123
|
* @return {Template} */
|
|
@@ -2394,6 +3154,21 @@ function itemClose(ng, item) {
|
|
|
2394
3154
|
return tpl.html === item.html && tpl.svgMode === item.svgMode;
|
|
2395
3155
|
}
|
|
2396
3156
|
|
|
3157
|
+
/**
|
|
3158
|
+
* Is this item somewhere in the list the previous render drew, i.e. did it move rather than
|
|
3159
|
+
* appear? A plain scan rather than a map, because it runs once and usually answers on the way
|
|
3160
|
+
* past.
|
|
3161
|
+
* @param lastItems {Array}
|
|
3162
|
+
* @param oldLen {int}
|
|
3163
|
+
* @param item {*}
|
|
3164
|
+
* @return {boolean} */
|
|
3165
|
+
function itemIsElsewhere(lastItems, oldLen, item) {
|
|
3166
|
+
for (let i=0; i<oldLen; i++)
|
|
3167
|
+
if (lastItems[i] === item)
|
|
3168
|
+
return true;
|
|
3169
|
+
return false;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
2397
3172
|
/**
|
|
2398
3173
|
* Insert all of ng's nodes before anchor within parent.
|
|
2399
3174
|
* @param parent {Node}
|
|
@@ -2490,12 +3265,6 @@ function reconcileNodes(parentNode, oldNodes, newNodes, before) {
|
|
|
2490
3265
|
* matches NodeGroups to new templates by this key. */
|
|
2491
3266
|
class PathToKey extends Path {
|
|
2492
3267
|
|
|
2493
|
-
/**
|
|
2494
|
-
* @param exprs {Expr[]} Only the first is used. */
|
|
2495
|
-
apply(exprs) {
|
|
2496
|
-
this.parentNg.key = exprs[0];
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
3268
|
applySingle(expr) {
|
|
2500
3269
|
this.parentNg.key = expr;
|
|
2501
3270
|
}
|
|
@@ -2517,9 +3286,9 @@ class PathToComponent extends Path {
|
|
|
2517
3286
|
* Call render() on the component pointed to by this Path.
|
|
2518
3287
|
* And instantiate it (from a -solarite-placeholder element) if it hasn't been done yet.
|
|
2519
3288
|
* @param exprs {Expr[][]} Expressions to evaluate for each attribute to pass to the constructor.
|
|
2520
|
-
* This is different than other Path.
|
|
3289
|
+
* This is different than other Path.applyAll() functions which only receive Expr[] and not Expr[][].
|
|
2521
3290
|
* Because here we're receiving an array of arrays of expressions, one for each dynamic attribute. */
|
|
2522
|
-
|
|
3291
|
+
applyAll(exprs) {
|
|
2523
3292
|
|
|
2524
3293
|
|
|
2525
3294
|
|
|
@@ -2536,8 +3305,15 @@ class PathToComponent extends Path {
|
|
|
2536
3305
|
for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
|
|
2537
3306
|
if (attribPath instanceof PathToKey) // The list key is never a component arg.
|
|
2538
3307
|
continue;
|
|
3308
|
+
// Event attributes like onchange=${...} are bound with addEventListener when the
|
|
3309
|
+
// PathToEvent itself is applied. They must not also become constructor fields:
|
|
3310
|
+
// a component that assigns its fields onto itself would set the native on*
|
|
3311
|
+
// property, making the handler fire a second time with only the (event) argument
|
|
3312
|
+
// instead of Solarite's documented (event, element) signature.
|
|
3313
|
+
if (attribPath instanceof PathToEvent)
|
|
3314
|
+
continue;
|
|
2539
3315
|
if (attribPath instanceof PathToAttribValue) {
|
|
2540
|
-
let name = Util.dashesToCamel(attribPath.
|
|
3316
|
+
let name = Util.dashesToCamel(attribPath.attribName);
|
|
2541
3317
|
|
|
2542
3318
|
// Resolve two way bindimg path before we pass it to the component.
|
|
2543
3319
|
let value = attribPath.getValue(exprs[i]);
|
|
@@ -2567,8 +3343,27 @@ class PathToComponent extends Path {
|
|
|
2567
3343
|
// 2a. Instantiate component
|
|
2568
3344
|
let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
|
|
2569
3345
|
let Constructor = customElements.get(tagName);
|
|
2570
|
-
|
|
2571
|
-
|
|
3346
|
+
|
|
3347
|
+
// Not defined yet (e.g. the module is being lazily imported): keep the placeholder
|
|
3348
|
+
// and instantiate when the definition lands, like a native custom-element upgrade.
|
|
3349
|
+
// deferredExprs always holds the LATEST exprs so re-renders while undefined win.
|
|
3350
|
+
if (!Constructor) {
|
|
3351
|
+
this.deferredExprs = exprs;
|
|
3352
|
+
if (!this.whenDefinedPending) {
|
|
3353
|
+
this.whenDefinedPending = true;
|
|
3354
|
+
console.warn(`Solarite: <${tagName}> is not defined yet; waiting for customElements.define().`);
|
|
3355
|
+
customElements.whenDefined(tagName).then(() => {
|
|
3356
|
+
this.whenDefinedPending = false;
|
|
3357
|
+
let deferred = this.deferredExprs;
|
|
3358
|
+
this.deferredExprs = null;
|
|
3359
|
+
// Skip if a newer render already instantiated or replaced the placeholder.
|
|
3360
|
+
if (deferred && this.nodeMarker === el && el.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
|
|
3361
|
+
this.applyAll(deferred);
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
3364
|
+
Globals$1.currentSlotChildren = null;
|
|
3365
|
+
return;
|
|
3366
|
+
}
|
|
2572
3367
|
|
|
2573
3368
|
Globals$1.currentSlotChildren = [...el.childNodes]; // TODO: Does this need to be a stack?
|
|
2574
3369
|
let newEl = new Constructor(attribs);
|
|
@@ -2586,8 +3381,10 @@ class PathToComponent extends Path {
|
|
|
2586
3381
|
for (let name in attribs) {
|
|
2587
3382
|
let val = attribs[name];
|
|
2588
3383
|
let valType = typeof val;
|
|
3384
|
+
// Only true and false can reach here, so the undefined/null halves of the
|
|
3385
|
+
// falsy test this used to spell out could never have decided anything.
|
|
2589
3386
|
if (valType === 'boolean') {
|
|
2590
|
-
if (val
|
|
3387
|
+
if (val)
|
|
2591
3388
|
newEl.setAttribute(name, '');
|
|
2592
3389
|
}
|
|
2593
3390
|
|
|
@@ -2600,7 +3397,7 @@ class PathToComponent extends Path {
|
|
|
2600
3397
|
// 2c. If an id pointed at the placeholder, update it to point to the new element.
|
|
2601
3398
|
let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
|
|
2602
3399
|
if (id)
|
|
2603
|
-
delve(this.parentNg.
|
|
3400
|
+
delve(this.parentNg.getRootEl(), id.split(/\./g), newEl);
|
|
2604
3401
|
|
|
2605
3402
|
// 2d. Update paths to use replaced element.
|
|
2606
3403
|
let ng = this.parentNg;
|
|
@@ -2626,7 +3423,7 @@ class PathToComponent extends Path {
|
|
|
2626
3423
|
for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
|
|
2627
3424
|
attribPath.parentNg = this.parentNg;
|
|
2628
3425
|
attribPath.nodeMarker = newEl;
|
|
2629
|
-
attribPath.
|
|
3426
|
+
attribPath.applyAll(exprs[i]);
|
|
2630
3427
|
}
|
|
2631
3428
|
|
|
2632
3429
|
// 2e. Swap it to the DOM.
|
|
@@ -2645,13 +3442,10 @@ class PathToComponent extends Path {
|
|
|
2645
3442
|
* @param pathOffset {int}
|
|
2646
3443
|
* @return {Path} */
|
|
2647
3444
|
clone(newRoot, pathOffset=0) {
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
let result =
|
|
3445
|
+
// A component path's nodeBefore is always null (the constructor discards it), so the
|
|
3446
|
+
// base clone() resolves only the nodeMarker and hands back a new PathToComponent.
|
|
3447
|
+
let result = super.clone(newRoot, pathOffset);
|
|
2651
3448
|
result.attribPaths = this.attribPaths.map(path => path.clone(newRoot, pathOffset));
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
3449
|
return result;
|
|
2656
3450
|
}
|
|
2657
3451
|
|
|
@@ -2671,7 +3465,7 @@ class Shell {
|
|
|
2671
3465
|
|
|
2672
3466
|
/**
|
|
2673
3467
|
* @type {DocumentFragment|Text} DOM parent of the shell's nodes. */
|
|
2674
|
-
|
|
3468
|
+
docFrag;
|
|
2675
3469
|
|
|
2676
3470
|
/** @type {Path[]} Paths to where expressions should go. */
|
|
2677
3471
|
paths = [];
|
|
@@ -2691,10 +3485,22 @@ class Shell {
|
|
|
2691
3485
|
/** @type {boolean} True if any of this Shell's own paths is a PathToComponent. */
|
|
2692
3486
|
hasComponentPaths = false;
|
|
2693
3487
|
|
|
3488
|
+
/** @type {boolean} True if any path binds an attribute that's a live HTML property
|
|
3489
|
+
* (checked, value, selected — Util.isHtmlProp). Users flip those underneath the template,
|
|
3490
|
+
* so "expression unchanged" doesn't mean "DOM unchanged" and the skip shortcuts exempt them. */
|
|
3491
|
+
hasLivePropPaths = false;
|
|
3492
|
+
|
|
2694
3493
|
/** @type {boolean} True if every path consumes exactly one expression and none are components.
|
|
2695
3494
|
* Lets NodeGroup.applyExprs() use a fast loop without allocating per-path expression arrays. */
|
|
2696
3495
|
pathsSingleExpr = false;
|
|
2697
3496
|
|
|
3497
|
+
/** @type {boolean} True when a NodeGroup whose values are unchanged still has work to do:
|
|
3498
|
+
* components re-render so changes deeper in the tree surface, and live HTML properties are
|
|
3499
|
+
* rewritten because a click can flip them underneath the cached expression. The list scans
|
|
3500
|
+
* check this before calling PathToNodes.refreshSameItem(), so the overwhelmingly common
|
|
3501
|
+
* unchanged row costs one field read instead of a call. */
|
|
3502
|
+
needsRefresh = false;
|
|
3503
|
+
|
|
2698
3504
|
/** @type {boolean} True if this Shell has any ids, styles, or scripts. */
|
|
2699
3505
|
hasEmbeds = false;
|
|
2700
3506
|
|
|
@@ -2710,6 +3516,52 @@ class Shell {
|
|
|
2710
3516
|
* with no per-instance Path objects. See the stampPaths setup in the constructor. */
|
|
2711
3517
|
stampable = false;
|
|
2712
3518
|
|
|
3519
|
+
// The remaining fields are only filled in for some shells (resolve program, stampable),
|
|
3520
|
+
// but they're all declared here so every Shell instance shares one hidden class.
|
|
3521
|
+
// NodeGroup's per-row code (its constructor, applyStamp, resolveStampSlots) reads these
|
|
3522
|
+
// off whichever shell it's given, and a single shape keeps those loads monomorphic.
|
|
3523
|
+
|
|
3524
|
+
/** @type {?string} The Template close key, cached here by the NodeGroup constructor so
|
|
3525
|
+
* each new template row skips a WeakMap lookup. See Template.getCloseKey(). */
|
|
3526
|
+
closeKey;
|
|
3527
|
+
|
|
3528
|
+
/** @type {?int[]} The resolve program: flat [parentSlot, childIndex] pairs in dependency
|
|
3529
|
+
* order; pair i fills slot i+1, slot 0 being the fragment. Built by buildResolveProgram();
|
|
3530
|
+
* undefined for shells with components. */
|
|
3531
|
+
resolveOps;
|
|
3532
|
+
|
|
3533
|
+
/** @type {?Node[]} Reusable scratch array for resolved nodes; safe because resolution
|
|
3534
|
+
* never re-enters. */
|
|
3535
|
+
resolveSlots;
|
|
3536
|
+
|
|
3537
|
+
// The stamp program, set only when stampable is true:
|
|
3538
|
+
|
|
3539
|
+
/** @type {?int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
|
|
3540
|
+
nodesPathIdx;
|
|
3541
|
+
|
|
3542
|
+
/** @type {?Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
|
|
3543
|
+
stampPaths;
|
|
3544
|
+
|
|
3545
|
+
/** @type {?Uint8Array} Opcode per path; see the stamp-program comment in the constructor. */
|
|
3546
|
+
stampOp;
|
|
3547
|
+
|
|
3548
|
+
/** @type {?Uint16Array} paths[i].markerSlot, in a flat array so the hot loop
|
|
3549
|
+
* doesn't load the Path object to find its slot. */
|
|
3550
|
+
stampSlot;
|
|
3551
|
+
|
|
3552
|
+
/** @type {?Path[]} Per-path extra the stamp program needs: the event stamper for op 3
|
|
3553
|
+
* (it carries delegatedKey and eventName), the attribute name for op 4, null otherwise. */
|
|
3554
|
+
stampAux;
|
|
3555
|
+
|
|
3556
|
+
/** @type {?string[]} The delegatable event names this shell binds, so a loop can register
|
|
3557
|
+
* their dispatchers once for the whole run of rows instead of testing every bound node. */
|
|
3558
|
+
stampEventNames;
|
|
3559
|
+
|
|
3560
|
+
/** @type {?Uint8Array} Per-path flags the in-place rewrite loop needs, so it reads one byte
|
|
3561
|
+
* from a flat array instead of two properties from a Path object it otherwise wouldn't
|
|
3562
|
+
* touch. Bit 1 = the path binds a live HTML property, bit 2 = it's a whole-parent child. */
|
|
3563
|
+
stampFlags;
|
|
3564
|
+
|
|
2713
3565
|
/**
|
|
2714
3566
|
* Create the nodes but without filling in the expressions.
|
|
2715
3567
|
* This is useful because the expression-less nodes created by a template can be cached.
|
|
@@ -2723,7 +3575,7 @@ class Shell {
|
|
|
2723
3575
|
|
|
2724
3576
|
// If no html tags or entities, just create a text node.
|
|
2725
3577
|
if (html.length === 1 && !html[0].match(/[<&]/)) {
|
|
2726
|
-
this.
|
|
3578
|
+
this.docFrag = Globals$1.doc.createTextNode(html[0]);
|
|
2727
3579
|
return;
|
|
2728
3580
|
}
|
|
2729
3581
|
|
|
@@ -2741,29 +3593,29 @@ class Shell {
|
|
|
2741
3593
|
let frag = Globals$1.doc.createDocumentFragment();
|
|
2742
3594
|
while (svgEl.firstChild)
|
|
2743
3595
|
frag.append(svgEl.firstChild);
|
|
2744
|
-
this.
|
|
3596
|
+
this.docFrag = frag;
|
|
2745
3597
|
}
|
|
2746
3598
|
else {
|
|
2747
3599
|
template.innerHTML = htmlWithPlaceholders;
|
|
2748
|
-
this.
|
|
3600
|
+
this.docFrag = template.content;
|
|
2749
3601
|
}
|
|
2750
3602
|
}
|
|
2751
3603
|
else { // Create one text node, so shell isn't empty and NodeGroups created from it have something to point the startNode and endNode at.
|
|
2752
3604
|
template.content.append(Globals$1.doc.createTextNode(''));
|
|
2753
|
-
this.
|
|
3605
|
+
this.docFrag = template.content;
|
|
2754
3606
|
}
|
|
2755
3607
|
|
|
2756
3608
|
// 1b. Remove whitespace-only text nodes inside table-structure elements.
|
|
2757
3609
|
// The parser foster-parents non-whitespace text out of tables, and whitespace-only
|
|
2758
3610
|
// text between cells/rows is never rendered, so removing it is invisible.
|
|
2759
3611
|
// Smaller fragments make cloning, path resolution, and insertion faster.
|
|
2760
|
-
stripTableWhitespace(this.
|
|
3612
|
+
stripTableWhitespace(this.docFrag);
|
|
2761
3613
|
|
|
2762
3614
|
// 2. Find placeholders
|
|
2763
3615
|
let node;
|
|
2764
3616
|
let toRemove = [];
|
|
2765
3617
|
let placeholdersUsed = 0;
|
|
2766
|
-
const walker = Globals$1.doc.createTreeWalker(this.
|
|
3618
|
+
const walker = Globals$1.doc.createTreeWalker(this.docFrag, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
|
|
2767
3619
|
while (node = walker.nextNode()) {
|
|
2768
3620
|
|
|
2769
3621
|
// Remove previous elements after each iteration, so paths will still be calculated correctly.
|
|
@@ -2781,13 +3633,20 @@ class Shell {
|
|
|
2781
3633
|
// The reserved key attribute identifies this template within a keyed list.
|
|
2782
3634
|
// It's consumed here and never written to the DOM or passed to components.
|
|
2783
3635
|
if (attr.name === 'key') {
|
|
3636
|
+
|
|
3637
|
+
// These three are template-authoring mistakes, and every one of them fails SILENTLY if
|
|
3638
|
+
// it isn't caught: the reconciler would key rows on a garbage value and reuse the wrong
|
|
3639
|
+
// DOM, with nothing reported. So they ship, unlike the assertions elsewhere in this
|
|
3640
|
+
// file. The cost is one regex split per unique template \u2014 never per render, never per
|
|
3641
|
+
// row \u2014 which is why they are affordable to keep.
|
|
2784
3642
|
let parts = attr.value.split(/[\ue000-\uf8ff]/g);
|
|
2785
3643
|
if (parts.length !== 2 || parts[0] !== '' || parts[1] !== '')
|
|
2786
|
-
throw new Error(`
|
|
2787
|
-
if (node.parentNode !== this.
|
|
2788
|
-
throw new Error(`
|
|
3644
|
+
throw new Error(`Solarite: key must be one whole expression.`);
|
|
3645
|
+
if (node.parentNode !== this.docFrag)
|
|
3646
|
+
throw new Error(`Solarite: key must be on a top-level element.`);
|
|
2789
3647
|
if (this.keyIndex >= 0)
|
|
2790
|
-
throw new Error(`
|
|
3648
|
+
throw new Error(`Solarite: duplicate key attribute.`);
|
|
3649
|
+
|
|
2791
3650
|
this.keyIndex = attr.value.charCodeAt(0) - attribPlaceholder;
|
|
2792
3651
|
|
|
2793
3652
|
let path = new PathToKey(null, node);
|
|
@@ -2832,19 +3691,30 @@ class Shell {
|
|
|
2832
3691
|
}
|
|
2833
3692
|
|
|
2834
3693
|
placeholdersUsed += parts.length - 1;
|
|
2835
|
-
//
|
|
2836
|
-
//
|
|
2837
|
-
//
|
|
2838
|
-
//
|
|
2839
|
-
//
|
|
2840
|
-
|
|
3694
|
+
// An attribute whose whole value is one expression is removed from the shell:
|
|
3695
|
+
// its stamped value is always the empty string, so every clone would carry a
|
|
3696
|
+
// useless empty attribute that costs storage on creation and a slot in the
|
|
3697
|
+
// element's attribute list forever, and apply() writes the real value anyway
|
|
3698
|
+
// (a missing attribute reads back as '', so an empty expression still writes
|
|
3699
|
+
// nothing). Event attributes must be removed for the same reason plus a
|
|
3700
|
+
// stricter one: an empty onclick="" violates a strict CSP when the event fires.
|
|
3701
|
+
// In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the
|
|
3702
|
+
// placeholders stripped out makes the browser log parse errors, both here and
|
|
3703
|
+
// when the fragment is cloned, so those are removed whether or not they're whole.
|
|
3704
|
+
if (svgMode || !nonEmptyParts)
|
|
2841
3705
|
node.removeAttribute(attr.name);
|
|
2842
|
-
|
|
3706
|
+
|
|
3707
|
+
// setAttribute throws only when the template author wrote a name the browser
|
|
3708
|
+
// refuses, such as one holding a space or a quote. That name comes from a tagged
|
|
3709
|
+
// template literal's static text, so it is a typo that surfaces the first time the
|
|
3710
|
+
// template renders and can never appear later or for only some users. Development
|
|
3711
|
+
// therefore wraps the call to rethrow with the attribute name and the tag included,
|
|
3712
|
+
// because the browser's own DOMException names neither and leaves the author
|
|
3713
|
+
// hunting. Production ships the bare call and lets that DOMException through: the
|
|
3714
|
+
// friendlier wording is only worth its bytes to whoever can still fix the template.
|
|
3715
|
+
else
|
|
2843
3716
|
node.setAttribute(attr.name, parts.join(''));
|
|
2844
|
-
|
|
2845
|
-
catch (e) {
|
|
2846
|
-
throw new Error(`Error setting attribute "${attr.name}" on node <${node.tagName}>: ${e.message}`);
|
|
2847
|
-
}
|
|
3717
|
+
|
|
2848
3718
|
}
|
|
2849
3719
|
}
|
|
2850
3720
|
}
|
|
@@ -2866,7 +3736,7 @@ class Shell {
|
|
|
2866
3736
|
else if (node.nodeType === 8 && node.nodeValue === '!✨!') {
|
|
2867
3737
|
|
|
2868
3738
|
if (node?.parentNode?.closest && node?.parentNode?.closest('[contenteditable]'))
|
|
2869
|
-
throw new Error(`
|
|
3739
|
+
throw new Error(`Solarite: no \${...} inside contenteditable; use value="\${...}".`);
|
|
2870
3740
|
|
|
2871
3741
|
let parent = node.parentNode;
|
|
2872
3742
|
|
|
@@ -2913,11 +3783,6 @@ class Shell {
|
|
|
2913
3783
|
}
|
|
2914
3784
|
}
|
|
2915
3785
|
|
|
2916
|
-
// Comments become text nodes when inside textareas.
|
|
2917
|
-
else if (node.nodeType === 3 && node.parentNode?.tagName === 'TEXTAREA' && node.textContent.includes('<!--!✨!-->'))
|
|
2918
|
-
throw new Error(`Textarea can't have expressions inside them. Use <textarea value="\${...}"> instead.`);
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
3786
|
// Sometimes users will comment out a block of html code that has expressions.
|
|
2922
3787
|
// Here we look for expressions in comments.
|
|
2923
3788
|
// We don't actually update them dynamically, but we still add paths for them.
|
|
@@ -2931,29 +3796,39 @@ class Shell {
|
|
|
2931
3796
|
}
|
|
2932
3797
|
}
|
|
2933
3798
|
|
|
2934
|
-
//
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
3799
|
+
// A few elements have raw-text bodies, which the html parser reads as literal characters
|
|
3800
|
+
// rather than as markup. A comment placeholder written inside one therefore never becomes
|
|
3801
|
+
// a comment node; it arrives here as ordinary text. A textarea can't support expressions
|
|
3802
|
+
// in its body at all, while script and style can, by splitting their text around each
|
|
3803
|
+
// placeholder so that every expression gets a text node of its own to write into.
|
|
3804
|
+
else if (node.nodeType === 3) { // Node.TEXT_NODE
|
|
3805
|
+
let parentName = node.parentNode?.nodeName;
|
|
3806
|
+
|
|
3807
|
+
if (parentName === 'TEXTAREA' && node.textContent.includes(commentPlaceholder))
|
|
3808
|
+
throw new Error(`Solarite: no \${...} inside textarea; use value="\${...}".`);
|
|
3809
|
+
|
|
3810
|
+
else if (parentName === 'SCRIPT' || parentName === 'STYLE') {
|
|
3811
|
+
let parts = node.textContent.split(commentPlaceholder);
|
|
3812
|
+
if (parts.length > 1) {
|
|
3813
|
+
|
|
3814
|
+
// Every part is inserted before the original node, in order, so from the second
|
|
3815
|
+
// part onward the text node made on the previous iteration is already sitting
|
|
3816
|
+
// immediately before this one and serves as the new path's nodeBefore.
|
|
3817
|
+
for (let i = 0; i<parts.length; i++) {
|
|
3818
|
+
let current = Globals$1.doc.createTextNode(parts[i]);
|
|
3819
|
+
node.parentNode.insertBefore(current, node);
|
|
3820
|
+
if (i > 0) {
|
|
3821
|
+
let path = new PathToNodes(current.previousSibling, current);
|
|
3822
|
+
this.paths.push(path);
|
|
3823
|
+
placeholdersUsed ++;
|
|
3824
|
+
|
|
3825
|
+
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
2951
3828
|
|
|
2952
|
-
|
|
3829
|
+
// Removing it here will mess up the treeWalker.
|
|
3830
|
+
toRemove.push(node);
|
|
2953
3831
|
}
|
|
2954
|
-
|
|
2955
|
-
// Removing them here will mess up the treeWalker.
|
|
2956
|
-
toRemove.push(node);
|
|
2957
3832
|
}
|
|
2958
3833
|
}
|
|
2959
3834
|
}
|
|
@@ -2962,31 +3837,37 @@ class Shell {
|
|
|
2962
3837
|
// Less than or equal because there can be one path to multiple expressions
|
|
2963
3838
|
// if those expressions are in the same attribute value.
|
|
2964
3839
|
if (placeholdersUsed !== html.length-1)
|
|
2965
|
-
throw new Error(`
|
|
3840
|
+
throw new Error(`Solarite: bad html or duplicate attribute: ${html.join('${...}')}`);
|
|
2966
3841
|
|
|
2967
3842
|
for (let path of this.paths) {
|
|
2968
|
-
|
|
2969
|
-
|
|
3843
|
+
// -1 when the path has no nodeBefore. Assigned unconditionally so every shell path
|
|
3844
|
+
// of a given class takes the same property-addition order and shares one hidden class.
|
|
3845
|
+
path.nodeBeforeIndex = path.nodeBefore
|
|
3846
|
+
? Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
|
|
3847
|
+
: -1;
|
|
2970
3848
|
|
|
2971
3849
|
// Must be calculated after we remove the toRemove nodes:
|
|
2972
3850
|
path.nodeMarkerPath = Path.get(path.nodeMarker);
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
3851
|
}
|
|
2976
3852
|
|
|
2977
3853
|
this.findEmbeds();
|
|
2978
|
-
this.buildResolveProgram();
|
|
2979
3854
|
|
|
3855
|
+
// This scan must run before buildResolveProgram(), which skips shells with components
|
|
3856
|
+
// and reads hasComponentPaths rather than walking the paths a second time.
|
|
2980
3857
|
this.pathsSingleExpr = true;
|
|
2981
3858
|
for (let path of this.paths) {
|
|
2982
3859
|
if (path instanceof PathToComponent) {
|
|
2983
3860
|
this.hasComponentPaths = true;
|
|
2984
3861
|
this.pathsSingleExpr = false;
|
|
2985
|
-
break; // Both facts are now decided.
|
|
2986
3862
|
}
|
|
2987
|
-
if (path.getExpressionCount() !== 1)
|
|
2988
|
-
this.pathsSingleExpr = false;
|
|
3863
|
+
else if (path.getExpressionCount() !== 1)
|
|
3864
|
+
this.pathsSingleExpr = false;
|
|
3865
|
+
if (path.isHtmlProperty) // needs the full scan — no early break
|
|
3866
|
+
this.hasLivePropPaths = true;
|
|
2989
3867
|
}
|
|
3868
|
+
this.needsRefresh = this.hasComponentPaths || (this.hasLivePropPaths && this.pathsSingleExpr);
|
|
3869
|
+
|
|
3870
|
+
this.buildResolveProgram();
|
|
2990
3871
|
|
|
2991
3872
|
// Stampable shells create NodeGroups without allocating any Path objects:
|
|
2992
3873
|
// NodeGroup.applyStamp() writes expressions through these shared stamper paths,
|
|
@@ -3011,13 +3892,46 @@ class Shell {
|
|
|
3011
3892
|
}
|
|
3012
3893
|
if (ok) {
|
|
3013
3894
|
this.stampable = true;
|
|
3014
|
-
|
|
3015
|
-
/** @type {int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
|
|
3016
3895
|
this.nodesPathIdx = nodesIdx;
|
|
3017
|
-
|
|
3018
|
-
/** @type {Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
|
|
3019
3896
|
this.stampPaths = this.paths.map(p => p.cloneWithNodes(null, p.nodeMarker));
|
|
3020
3897
|
|
|
3898
|
+
// Compiled stamp program: one opcode per path lets applyStamp() write a fresh
|
|
3899
|
+
// row through a flat branch chain instead of dispatching applySingle() per path.
|
|
3900
|
+
// 0 = generic (shared stamper fallback), 1 = list key (no DOM), 2 = wholeParent
|
|
3901
|
+
// child text, 3 = delegatable single-expression event (written as node expandos
|
|
3902
|
+
// when the root delegates, the default).
|
|
3903
|
+
let n = this.paths.length;
|
|
3904
|
+
this.stampOp = new Uint8Array(n);
|
|
3905
|
+
this.stampSlot = new Uint16Array(n);
|
|
3906
|
+
this.stampAux = new Array(n).fill(null);
|
|
3907
|
+
this.stampFlags = new Uint8Array(n);
|
|
3908
|
+
|
|
3909
|
+
let eventNames = null;
|
|
3910
|
+
for (let i=0; i<n; i++) {
|
|
3911
|
+
let p = this.paths[i], sp = this.stampPaths[i];
|
|
3912
|
+
this.stampSlot[i] = p.markerSlot;
|
|
3913
|
+
this.stampFlags[i] = (sp.isHtmlProperty ? 1 : 0) | (sp.wholeParent ? 2 : 0);
|
|
3914
|
+
if (p instanceof PathToKey)
|
|
3915
|
+
this.stampOp[i] = 1;
|
|
3916
|
+
else if (sp.wholeParent)
|
|
3917
|
+
this.stampOp[i] = 2;
|
|
3918
|
+
else if (sp instanceof PathToEvent && sp.delegatedKey !== undefined && !sp.attrValue) {
|
|
3919
|
+
this.stampOp[i] = 3;
|
|
3920
|
+
this.stampAux[i] = sp;
|
|
3921
|
+
(eventNames ??= []).push(sp.eventName);
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
// A plain attribute holding one whole expression. The shell no longer carries
|
|
3925
|
+
// the attribute at all (see the placeholder handling above), so on a freshly
|
|
3926
|
+
// cloned row the value is known to be absent and a string can be written
|
|
3927
|
+
// without first reading back what's there.
|
|
3928
|
+
else if (sp instanceof PathToAttribValue && !sp.attrValue && !sp.isHtmlProperty
|
|
3929
|
+
&& !sp.isComponentAttrib) {
|
|
3930
|
+
this.stampOp[i] = 4;
|
|
3931
|
+
this.stampAux[i] = sp.attribName;
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
this.stampEventNames = eventNames;
|
|
3021
3935
|
}
|
|
3022
3936
|
}
|
|
3023
3937
|
|
|
@@ -3032,42 +3946,64 @@ class Shell {
|
|
|
3032
3946
|
* @param htmlChunks {string[]}
|
|
3033
3947
|
* @returns {string} Html with the placeholders in place. */
|
|
3034
3948
|
static addPlaceholders(htmlChunks) {
|
|
3035
|
-
let result =
|
|
3949
|
+
let result = '';
|
|
3950
|
+
|
|
3951
|
+
// Where the tokenizer is as it walks the chunks. An expression can sit in the middle of an attribute
|
|
3952
|
+
// value, so both of these have to survive from one chunk to the next. Nothing else has to: an
|
|
3953
|
+
// expression anywhere inside a tag gets the same attribute placeholder, so the machine only has to
|
|
3954
|
+
// know whether it is inside a tag at all, and whether a quoted value is currently open.
|
|
3955
|
+
let inTag = false; // True from the '<' that opens a tag or comment through the '>' that closes it.
|
|
3956
|
+
let quote = null; // The quote character that opened the attribute value we're inside of: null, '"', or "'".
|
|
3036
3957
|
|
|
3037
|
-
let htmlParser = new HtmlParser(); // Reset the context.
|
|
3038
3958
|
for (let i = 0; i < htmlChunks.length; i++) {
|
|
3039
|
-
let
|
|
3959
|
+
let html = htmlChunks[i];
|
|
3040
3960
|
|
|
3041
3961
|
// Append -solarite-placholder to web component tags, so we can pass args to them when they're instantiated.
|
|
3042
|
-
let lastIndex = 0;
|
|
3043
|
-
let
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
if (
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
//
|
|
3051
|
-
//
|
|
3052
|
-
|
|
3053
|
-
|
|
3962
|
+
let lastIndex = 0; // Start of the run of this chunk not yet copied into result.
|
|
3963
|
+
for (let j = 0; j < html.length; j++) {
|
|
3964
|
+
const char = html[j];
|
|
3965
|
+
|
|
3966
|
+
if (!inTag) {
|
|
3967
|
+
if (char === '<' && html[j + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
|
|
3968
|
+
inTag = true;
|
|
3969
|
+
|
|
3970
|
+
// A component suffix can only ever be added right here, at the '<' that opens the tag, so
|
|
3971
|
+
// the name is matched on the spot with a sticky regex rather than collected into a buffer
|
|
3972
|
+
// and matched later. The greedy tag-name class can't run past the name, because every
|
|
3973
|
+
// character that can follow a tag name is outside it.
|
|
3974
|
+
isWebComponentTagName.lastIndex = j;
|
|
3975
|
+
let match = isWebComponentTagName.exec(html);
|
|
3976
|
+
if (match) {
|
|
3977
|
+
let end = j + match[0].length;
|
|
3978
|
+
result += html.slice(lastIndex, end) + '-SOLARITE-PLACEHOLDER';
|
|
3979
|
+
lastIndex = end;
|
|
3980
|
+
}
|
|
3054
3981
|
}
|
|
3982
|
+
}
|
|
3055
3983
|
|
|
3056
|
-
|
|
3984
|
+
// Inside a tag, only two characters end anything: the quote that closes the value we're in, or,
|
|
3985
|
+
// when we're not in one, the '>' that closes the tag. Attribute names, '=', unquoted values and
|
|
3986
|
+
// whitespace all need no handling at all.
|
|
3987
|
+
else if (quote) {
|
|
3988
|
+
if (char === quote)
|
|
3989
|
+
quote = null;
|
|
3057
3990
|
}
|
|
3058
|
-
|
|
3059
|
-
|
|
3991
|
+
else if (char === '"' || char === "'")
|
|
3992
|
+
quote = char;
|
|
3993
|
+
else if (char === '>')
|
|
3994
|
+
inTag = false;
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
result += html.slice(lastIndex);
|
|
3060
3998
|
|
|
3061
3999
|
// Insert placeholders
|
|
3062
|
-
if (i < htmlChunks.length - 1)
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
result.push(String.fromCharCode(attribPlaceholder + i));
|
|
3067
|
-
}
|
|
4000
|
+
if (i < htmlChunks.length - 1)
|
|
4001
|
+
result += inTag
|
|
4002
|
+
? String.fromCharCode(attribPlaceholder + i)
|
|
4003
|
+
: commentPlaceholder; // Comment Placeholder. because we can't put text in between <tr> tags for example.
|
|
3068
4004
|
}
|
|
3069
4005
|
|
|
3070
|
-
return result
|
|
4006
|
+
return result;
|
|
3071
4007
|
}
|
|
3072
4008
|
|
|
3073
4009
|
/**
|
|
@@ -3079,21 +4015,18 @@ class Shell {
|
|
|
3079
4015
|
* this.ids
|
|
3080
4016
|
* this.staticComponents */
|
|
3081
4017
|
findEmbeds() {
|
|
3082
|
-
this.scripts = Array.prototype.map.call(this.
|
|
4018
|
+
this.scripts = Array.prototype.map.call(this.docFrag.querySelectorAll('script'), el => Path.get(el));
|
|
3083
4019
|
|
|
3084
4020
|
// TODO: only find styles that have Paths in them?
|
|
3085
|
-
this.styles = Array.prototype.map.call(this.
|
|
3086
|
-
|
|
3087
|
-
let idEls = this.fragment.querySelectorAll('[id],[data-id]');
|
|
3088
|
-
|
|
3089
|
-
// Check for valid id names.
|
|
3090
|
-
for (let el of idEls) {
|
|
3091
|
-
let id = el.getAttribute('data-id') || el.getAttribute('id');
|
|
3092
|
-
if (Globals$1.div.hasOwnProperty(id))
|
|
3093
|
-
throw new Error(`<${el.tagName.toLowerCase()} id="${id}"> can't override existing HTMLElement id property.`)
|
|
3094
|
-
}
|
|
4021
|
+
this.styles = Array.prototype.map.call(this.docFrag.querySelectorAll('style'), el => Path.get(el));
|
|
3095
4022
|
|
|
3096
|
-
|
|
4023
|
+
// An id that would clobber a built-in element property is reported by Util.bindId(), which
|
|
4024
|
+
// asks the real component object, with `in`, at the moment the binding happens. The check
|
|
4025
|
+
// that used to stand here asked Globals.div.hasOwnProperty(id) instead, and a freshly
|
|
4026
|
+
// created element has no own properties at all — every DOM property an element exposes
|
|
4027
|
+
// lives on its interface prototype — so that test could never be true and the error it
|
|
4028
|
+
// guarded was never reachable.
|
|
4029
|
+
this.ids = Array.prototype.map.call(this.docFrag.querySelectorAll('[id],[data-id]'), el => Path.get(el));
|
|
3097
4030
|
|
|
3098
4031
|
this.hasEmbeds = this.ids.length > 0 || this.styles.length > 0 || this.scripts.length > 0;
|
|
3099
4032
|
}
|
|
@@ -3104,25 +4037,37 @@ class Shell {
|
|
|
3104
4037
|
* Replaces per-path root-to-node walks in the hot NodeGroup creation path.
|
|
3105
4038
|
* Skipped for shells with components, whose clone() has special attribPaths behavior. */
|
|
3106
4039
|
buildResolveProgram() {
|
|
3107
|
-
|
|
3108
|
-
for (let path of this.paths)
|
|
3109
|
-
if (path instanceof PathToComponent) {
|
|
3110
|
-
hasComponents = true;
|
|
3111
|
-
break;
|
|
3112
|
-
}
|
|
3113
|
-
if (hasComponents || !this.paths.length)
|
|
4040
|
+
if (this.hasComponentPaths || !this.paths.length)
|
|
3114
4041
|
return;
|
|
3115
4042
|
|
|
3116
4043
|
let ops = [];
|
|
3117
4044
|
let slotOf = new Map();
|
|
3118
|
-
let frag = this.
|
|
4045
|
+
let frag = this.docFrag;
|
|
3119
4046
|
let nextSlot = 1;
|
|
3120
4047
|
let getSlot = node => {
|
|
3121
4048
|
if (node === frag)
|
|
3122
4049
|
return 0;
|
|
3123
4050
|
let s = slotOf.get(node);
|
|
3124
4051
|
if (s === undefined) {
|
|
3125
|
-
|
|
4052
|
+
// Two ways to reach a node, costing one pointer step each: walk forward from an
|
|
4053
|
+
// already-resolved earlier sibling, or take the parent's firstChild and walk
|
|
4054
|
+
// forward. Sibling steps win whenever they're no more numerous, and they can
|
|
4055
|
+
// also spare the parent a slot of its own — in a row of cells, resolving each
|
|
4056
|
+
// <td> from the previous one is one step instead of firstChild plus its index.
|
|
4057
|
+
let d = 0, from = -1;
|
|
4058
|
+
for (let sib = node.previousSibling; sib; sib = sib.previousSibling) {
|
|
4059
|
+
d++;
|
|
4060
|
+
let ss = slotOf.get(sib);
|
|
4061
|
+
if (ss !== undefined) {
|
|
4062
|
+
from = ss;
|
|
4063
|
+
break;
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
let index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
|
|
4067
|
+
if (from >= 0 && d <= index + 1)
|
|
4068
|
+
ops.push(from, -d); // A negative step count means "walk nextSibling from that slot".
|
|
4069
|
+
else
|
|
4070
|
+
ops.push(getSlot(node.parentNode), index);
|
|
3126
4071
|
s = nextSlot++;
|
|
3127
4072
|
slotOf.set(node, s);
|
|
3128
4073
|
}
|
|
@@ -3133,10 +4078,7 @@ class Shell {
|
|
|
3133
4078
|
path.beforeSlot = path.nodeBefore ? getSlot(path.nodeBefore) : -1;
|
|
3134
4079
|
}
|
|
3135
4080
|
|
|
3136
|
-
/** @type {?int[]} Flat [parentSlot, childIndex] pairs; pair i fills slot i+1. */
|
|
3137
4081
|
this.resolveOps = ops;
|
|
3138
|
-
|
|
3139
|
-
/** @type {Node[]} Reusable scratch array for resolved nodes; safe because resolution never re-enters. */
|
|
3140
4082
|
this.resolveSlots = new Array(nextSlot);
|
|
3141
4083
|
|
|
3142
4084
|
// A lone root element means slot 1 is always that element (the first op pair is [0, 0]),
|
|
@@ -3181,6 +4123,15 @@ class Shell {
|
|
|
3181
4123
|
|
|
3182
4124
|
const commentPlaceholder = `<!--!✨!-->`;
|
|
3183
4125
|
|
|
4126
|
+
// A tag name with a dash in the middle, which is what makes an element a web component. addPlaceholders()
|
|
4127
|
+
// tests this at each '<' that opens a tag, and a match gets -solarite-placeholder appended to its tag name.
|
|
4128
|
+
// That way we can gather a component's constructor arguments and its children before we call its constructor;
|
|
4129
|
+
// later PathToComponent.applyAll() replaces the placeholder tag with the real component. The suffix is written in
|
|
4130
|
+
// caps wherever it appears, so that the several copies of it in this project compress well. It's sticky rather
|
|
4131
|
+
// than anchored so it can be tested at an offset within the chunk instead of against a sliced-out token.
|
|
4132
|
+
// Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
|
|
4133
|
+
const isWebComponentTagName = /<\/?[a-z][a-z0-9]*-[a-z0-9-]+/iy;
|
|
4134
|
+
|
|
3184
4135
|
// Elements whose whitespace-only text children are never rendered.
|
|
3185
4136
|
const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
|
|
3186
4137
|
|
|
@@ -3209,6 +4160,50 @@ const attribPlaceholder = 0xe000; // https://en.wikipedia.org/wiki/Private_Use_A
|
|
|
3209
4160
|
|
|
3210
4161
|
/** @typedef {boolean|string|number|function|Object|Array|Date|Node|Template} Expr */
|
|
3211
4162
|
|
|
4163
|
+
/** Stand-in Shell for text NodeGroups, which are never parsed from html. Its default field
|
|
4164
|
+
* values (no components, no live properties, no single-expression paths) are exactly what the
|
|
4165
|
+
* per-row code must see for a bare Text node, so ng.shell is never null. */
|
|
4166
|
+
const textShell = new Shell();
|
|
4167
|
+
|
|
4168
|
+
// The Shell whose delegated dispatchers a root last registered, kept on the RootNodeGroup so
|
|
4169
|
+
// that a run of rows checks one field instead of asking at every bound node. A Symbol rather
|
|
4170
|
+
// than a declared field, since only root NodeGroups ever carry it and a declared field would
|
|
4171
|
+
// cost a slot on every row. The delegation mode isn't part of it: it comes from the root's
|
|
4172
|
+
// render options, which are fixed when the root is created.
|
|
4173
|
+
const lastStampedShellKey = Symbol('solariteStampedShell');
|
|
4174
|
+
|
|
4175
|
+
/**
|
|
4176
|
+
* Run a Shell's precomputed resolve program (see Shell.buildResolveProgram) into the shell's
|
|
4177
|
+
* shared slots array, which the caller has already seeded with its starting node.
|
|
4178
|
+
* Each node is reached with firstChild/nextSibling pointer walks instead of childNodes[index];
|
|
4179
|
+
* the live NodeList indexing is markedly slower, and the indices are small (markers are
|
|
4180
|
+
* elements, often the first child after whitespace stripping). A negative step count means the
|
|
4181
|
+
* program reaches this node by walking forward from an earlier sibling's slot instead of from
|
|
4182
|
+
* its parent.
|
|
4183
|
+
* @param slots {Node[]} The shell's shared scratch array; slot 0 is the fragment.
|
|
4184
|
+
* @param ops {int[]} Flat [parentSlot, childIndex] pairs in dependency order.
|
|
4185
|
+
* @param i {int} Index of the first op pair to run; earlier pairs are pre-seeded by the caller.
|
|
4186
|
+
* @param s {int} Slot that pair fills.
|
|
4187
|
+
* @return {Node[]} slots, so callers can resolve and use it in one expression. */
|
|
4188
|
+
function runResolveOps(slots, ops, i, s) {
|
|
4189
|
+
for (; i<ops.length; i+=2, s++) {
|
|
4190
|
+
let k = ops[i+1], node;
|
|
4191
|
+
if (k < 0) {
|
|
4192
|
+
node = slots[ops[i]];
|
|
4193
|
+
do
|
|
4194
|
+
node = node.nextSibling;
|
|
4195
|
+
while (++k < 0);
|
|
4196
|
+
}
|
|
4197
|
+
else {
|
|
4198
|
+
node = slots[ops[i]].firstChild;
|
|
4199
|
+
for (; k>0; k--)
|
|
4200
|
+
node = node.nextSibling;
|
|
4201
|
+
}
|
|
4202
|
+
slots[s] = node;
|
|
4203
|
+
}
|
|
4204
|
+
return slots;
|
|
4205
|
+
}
|
|
4206
|
+
|
|
3212
4207
|
/**
|
|
3213
4208
|
* A group of Nodes instantiated from a Shell, with Expr's filled in.
|
|
3214
4209
|
*
|
|
@@ -3242,11 +4237,11 @@ class NodeGroup {
|
|
|
3242
4237
|
* matched by PathToNodes.applyKeyed(). Undefined for unkeyed NodeGroups. */
|
|
3243
4238
|
key;
|
|
3244
4239
|
|
|
3245
|
-
/** @type {
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
4240
|
+
/** @type {Shell} The Shell this NodeGroup was cloned from, so the per-row code can read
|
|
4241
|
+
* hasComponentPaths/hasLivePropPaths/pathsSingleExpr and the stamp program off it instead
|
|
4242
|
+
* of copying them onto every instance and re-looking the Shell up on every apply.
|
|
4243
|
+
* Text NodeGroups get the shared empty textShell, which reports false for all of them. */
|
|
4244
|
+
shell;
|
|
3250
4245
|
|
|
3251
4246
|
/** @type {boolean} True until applyExprs() finishes the first time.
|
|
3252
4247
|
* While true, ancestor node caches can't reference this NodeGroup's nodes, so they don't need invalidation. */
|
|
@@ -3257,6 +4252,11 @@ class NodeGroup {
|
|
|
3257
4252
|
* @type {Node[]} Cached result of getNodes() used only for improving performance.*/
|
|
3258
4253
|
nodesCache;
|
|
3259
4254
|
|
|
4255
|
+
/** @type {?Node[]} Slot nodes resolved by the first rewriteStamp(); a stamped group's
|
|
4256
|
+
* element structure never changes while it stays stampable, so they're reused on every
|
|
4257
|
+
* later rewrite. Declared here so every NodeGroup keeps one monomorphic hidden class. */
|
|
4258
|
+
stampSlotsCache = null;
|
|
4259
|
+
|
|
3260
4260
|
/**
|
|
3261
4261
|
* A map between <style> Elements and their text content.
|
|
3262
4262
|
* This lets NodeGroup.updateStyles() see when the style text has changed.
|
|
@@ -3289,24 +4289,22 @@ class NodeGroup {
|
|
|
3289
4289
|
// If it's just a text node, skip a bunch of unnecessary steps.
|
|
3290
4290
|
// el can be an existing Text node to adopt, from PathToNodes' bare-text fast path.
|
|
3291
4291
|
if (template.isText) {
|
|
4292
|
+
this.shell = textShell;
|
|
3292
4293
|
this.closeKey = template.getCloseKey();
|
|
3293
4294
|
this.startNode = this.endNode = el || Globals$1.doc.createTextNode(template.html[0]);
|
|
3294
4295
|
}
|
|
3295
4296
|
|
|
3296
4297
|
else {
|
|
3297
4298
|
// Get a cached version of the parsed and instantiated html, and Paths:
|
|
3298
|
-
const shell = Shell.get(template.html, template.svgMode);
|
|
4299
|
+
const shell = this.shell = Shell.get(template.html, template.svgMode);
|
|
3299
4300
|
|
|
3300
4301
|
// The shell caches the close key so each new template doesn't repeat the WeakMap lookup.
|
|
3301
4302
|
this.closeKey = shell.closeKey ??= template.getCloseKey();
|
|
3302
4303
|
|
|
3303
|
-
this.hasComponentPaths = shell.hasComponentPaths;
|
|
3304
|
-
this.pathsSingleExpr = shell.pathsSingleExpr;
|
|
3305
|
-
|
|
3306
4304
|
// A lone root element is cloned directly, skipping a throwaway fragment wrapper.
|
|
3307
4305
|
// Only for child NodeGroups; RootNodeGroup's grafting expects a fragment.
|
|
3308
4306
|
if (shell.singleRoot && parentPath !== null) {
|
|
3309
|
-
const clone = shell.
|
|
4307
|
+
const clone = shell.docFrag.firstChild.cloneNode(true);
|
|
3310
4308
|
this.startNode = this.endNode = clone;
|
|
3311
4309
|
|
|
3312
4310
|
// Stampable shells skip path creation entirely; the first applyExprs() routes
|
|
@@ -3315,7 +4313,7 @@ class NodeGroup {
|
|
|
3315
4313
|
this.setPathsFromFragment(clone, shell, 0, true);
|
|
3316
4314
|
}
|
|
3317
4315
|
else {
|
|
3318
|
-
const shellFragment = shell.
|
|
4316
|
+
const shellFragment = shell.docFrag.cloneNode(true);
|
|
3319
4317
|
|
|
3320
4318
|
if (shellFragment.nodeType === 11) { // DocumentFragment
|
|
3321
4319
|
this.startNode = shellFragment.firstChild;
|
|
@@ -3356,8 +4354,12 @@ class NodeGroup {
|
|
|
3356
4354
|
* Dispatches expression handling to other functions depending on the path type.
|
|
3357
4355
|
* @param exprs {(*|*[]|function|Template)[]}
|
|
3358
4356
|
* @param includeNonComponents {boolean} False to only apply component paths,
|
|
3359
|
-
* used when the non-component exprs are known to be unchanged.
|
|
3360
|
-
|
|
4357
|
+
* used when the non-component exprs are known to be unchanged.
|
|
4358
|
+
* @param lastExprs {?Expr[]} The expressions applied last time, when the caller has them.
|
|
4359
|
+
* Paths that would provably do nothing with an unchanged expression are then skipped —
|
|
4360
|
+
* see Path.skipIfSame. A root template's event bindings are the usual beneficiaries:
|
|
4361
|
+
* they are the same handlers on every render, and re-binding them costs a call apiece. */
|
|
4362
|
+
applyExprs(exprs, includeNonComponents=true, lastExprs=null) {
|
|
3361
4363
|
|
|
3362
4364
|
|
|
3363
4365
|
|
|
@@ -3365,14 +4367,18 @@ class NodeGroup {
|
|
|
3365
4367
|
|
|
3366
4368
|
// Fast path: every path consumes exactly one expression and none are components,
|
|
3367
4369
|
// so skip the bookkeeping that maps expressions to paths.
|
|
3368
|
-
if (this.pathsSingleExpr) {
|
|
4370
|
+
if (this.shell.pathsSingleExpr) {
|
|
3369
4371
|
if (includeNonComponents) {
|
|
3370
4372
|
if (paths === null) { // Created from a stampable shell; no paths yet.
|
|
3371
4373
|
this.applyStamp(exprs);
|
|
3372
4374
|
return;
|
|
3373
4375
|
}
|
|
3374
|
-
for (let i = paths.length - 1; i >= 0; i--)
|
|
3375
|
-
paths[i]
|
|
4376
|
+
for (let i = paths.length - 1; i >= 0; i--) {
|
|
4377
|
+
let path = paths[i];
|
|
4378
|
+
if (lastExprs !== null && path.skipIfSame && lastExprs[i] === exprs[i])
|
|
4379
|
+
continue;
|
|
4380
|
+
path.applySingle(exprs[i]);
|
|
4381
|
+
}
|
|
3376
4382
|
|
|
3377
4383
|
if (this.styles)
|
|
3378
4384
|
this.updateStyles();
|
|
@@ -3399,7 +4405,7 @@ class NodeGroup {
|
|
|
3399
4405
|
let exprIndex = exprs.length; // Update exprs at paths.
|
|
3400
4406
|
let pathExprs = new Array(paths.length); // Store all the expressions that map to a single path. Only paths to attribute values can have more than one.
|
|
3401
4407
|
for (let i = paths.length - 1, path; path = paths[i]; i--) {
|
|
3402
|
-
if (i===0 && path instanceof PathToComponent && path.nodeMarker === this.
|
|
4408
|
+
if (i===0 && path instanceof PathToComponent && path.nodeMarker === this.getRootEl())
|
|
3403
4409
|
continue;
|
|
3404
4410
|
|
|
3405
4411
|
// Get the expressions associated with this path.
|
|
@@ -3411,10 +4417,10 @@ class NodeGroup {
|
|
|
3411
4417
|
// They use expressions from the paths that provide their attributes.
|
|
3412
4418
|
if (path instanceof PathToComponent) {
|
|
3413
4419
|
let attribExprs = pathExprs.slice(i+1, i+1 + path.attribPaths.length); // +1 b/c we move forward from the component path.
|
|
3414
|
-
path.
|
|
4420
|
+
path.applyAll(attribExprs);
|
|
3415
4421
|
}
|
|
3416
4422
|
else if (includeNonComponents)
|
|
3417
|
-
path.
|
|
4423
|
+
path.applyAll(pathExprs[i]);
|
|
3418
4424
|
}
|
|
3419
4425
|
|
|
3420
4426
|
// If there's leftover expressions, there's probably an issue with the Shell that created this NodeGroup,
|
|
@@ -3443,8 +4449,7 @@ class NodeGroup {
|
|
|
3443
4449
|
* falls back to materializing real paths and applying normally.
|
|
3444
4450
|
* @param exprs {Expr[]} */
|
|
3445
4451
|
applyStamp(exprs) {
|
|
3446
|
-
let
|
|
3447
|
-
let shell = Shell.get(template.html, template.svgMode);
|
|
4452
|
+
let shell = this.shell;
|
|
3448
4453
|
|
|
3449
4454
|
// 1. Bail to real paths when any child-node expression isn't a primitive.
|
|
3450
4455
|
let nodesIdx = shell.nodesPathIdx;
|
|
@@ -3460,27 +4465,71 @@ class NodeGroup {
|
|
|
3460
4465
|
}
|
|
3461
4466
|
}
|
|
3462
4467
|
|
|
3463
|
-
// 2. Resolve target nodes, then
|
|
4468
|
+
// 2. Resolve target nodes, then run the shell's compiled stamp program: a flat
|
|
4469
|
+
// opcode per path replaces per-path applySingle() dispatch (see Shell.stampOp).
|
|
3464
4470
|
let slots = this.resolveStampSlots(shell);
|
|
3465
|
-
let
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
4471
|
+
let ops = shell.stampOp, slotIdx = shell.stampSlot, aux = shell.stampAux;
|
|
4472
|
+
let stampers = shell.stampPaths;
|
|
4473
|
+
let rootNg = this.rootNg;
|
|
4474
|
+
let root = rootNg.rootEl;
|
|
4475
|
+
let opt = rootNg.renderOptions?.eventDelegation;
|
|
4476
|
+
let delegateDoc = opt === 'document';
|
|
4477
|
+
let delegateAll = opt === undefined || opt === true || delegateDoc;
|
|
4478
|
+
|
|
4479
|
+
// Register this shell's delegated dispatchers once for a whole run of rows. They live on
|
|
4480
|
+
// the root, not on the bound nodes, so asking per node — as the general binding path has
|
|
4481
|
+
// to — would be a call and a set lookup for every handler in the list.
|
|
4482
|
+
let names = shell.stampEventNames;
|
|
4483
|
+
if (names !== null && delegateAll && rootNg[lastStampedShellKey] !== shell) {
|
|
4484
|
+
for (let k=0; k<names.length; k++)
|
|
4485
|
+
ensureDelegatedDispatcher(root, names[k], delegateDoc);
|
|
4486
|
+
rootNg[lastStampedShellKey] = shell;
|
|
4487
|
+
}
|
|
4488
|
+
|
|
4489
|
+
let firstApply = this.firstApply;
|
|
4490
|
+
for (let i = ops.length - 1; i >= 0; i--) {
|
|
4491
|
+
let v = exprs[i];
|
|
4492
|
+
let o = ops[i];
|
|
4493
|
+
|
|
4494
|
+
// Whole-parent child text: the marker is the (freshly cloned, empty) only-child
|
|
4495
|
+
// slot. Child exprs are primitive here (step 1 bailed otherwise).
|
|
4496
|
+
if (o === 2) {
|
|
3475
4497
|
if (typeof v === 'number')
|
|
3476
4498
|
v += '';
|
|
3477
|
-
|
|
3478
|
-
|
|
4499
|
+
slots[slotIdx[i]].textContent = v;
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
// Delegatable event with a valid handler shape: write the node expandos
|
|
4503
|
+
// directly, mirroring bindEvent()'s delegated branch. An event-name-array
|
|
4504
|
+
// delegation option or an invalid value falls through to the generic stamper.
|
|
4505
|
+
else if (o === 3 && delegateAll
|
|
4506
|
+
&& (typeof v === 'function' || (Array.isArray(v) && typeof v[0] === 'function'))) {
|
|
4507
|
+
let sp = aux[i];
|
|
4508
|
+
let node = slots[slotIdx[i]];
|
|
4509
|
+
node[sp.delegatedKey] = v;
|
|
4510
|
+
node[delegatedRootKey] = root;
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
// A plain attribute on a freshly cloned row: the shell left it off, so an empty
|
|
4514
|
+
// value means there is simply nothing to write, and any other string can go
|
|
4515
|
+
// straight in without reading the attribute back first.
|
|
4516
|
+
else if (o === 4 && firstApply && typeof v === 'string') {
|
|
4517
|
+
if (v !== '')
|
|
4518
|
+
slots[slotIdx[i]].setAttribute(aux[i], v);
|
|
3479
4519
|
}
|
|
3480
4520
|
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
4521
|
+
// The list key never touches the DOM.
|
|
4522
|
+
else if (o === 1)
|
|
4523
|
+
this.key = v;
|
|
4524
|
+
|
|
4525
|
+
// Everything else (attributes, disabled delegation, odd values) goes through
|
|
4526
|
+
// the shared stamper's full applySingle() semantics.
|
|
4527
|
+
else {
|
|
4528
|
+
let stamper = stampers[i];
|
|
4529
|
+
stamper.nodeMarker = slots[slotIdx[i]];
|
|
4530
|
+
stamper.parentNg = this;
|
|
4531
|
+
stamper.applySingle(v);
|
|
4532
|
+
}
|
|
3484
4533
|
}
|
|
3485
4534
|
|
|
3486
4535
|
this.nodesCache = null;
|
|
@@ -3494,7 +4543,7 @@ class NodeGroup {
|
|
|
3494
4543
|
* @return {boolean} False when a child-node expression isn't primitive; the caller
|
|
3495
4544
|
* must then materialize paths and apply normally. */
|
|
3496
4545
|
rewriteStamp(template) {
|
|
3497
|
-
let shell =
|
|
4546
|
+
let shell = this.shell;
|
|
3498
4547
|
let newExprs = template.exprs;
|
|
3499
4548
|
let nodesIdx = shell.nodesPathIdx;
|
|
3500
4549
|
for (let i=0; i<nodesIdx.length; i++) {
|
|
@@ -3504,19 +4553,29 @@ class NodeGroup {
|
|
|
3504
4553
|
}
|
|
3505
4554
|
|
|
3506
4555
|
let oldExprs = this.template.exprs;
|
|
3507
|
-
let
|
|
3508
|
-
let slots =
|
|
3509
|
-
for (let i =
|
|
3510
|
-
|
|
4556
|
+
let stampers = shell.stampPaths, slotIdx = shell.stampSlot, flags = shell.stampFlags;
|
|
4557
|
+
let slots = this.stampSlotsCache; // Nodes are resolved only if something actually changed, then cached.
|
|
4558
|
+
for (let i = stampers.length - 1; i >= 0; i--) {
|
|
4559
|
+
// Live HTML properties (checked etc., boolean-valued) are exempt from the
|
|
4560
|
+
// unchanged-value skip: a user's click flips the DOM property underneath the cached
|
|
4561
|
+
// expression, and applySingle() compares against the live node before writing.
|
|
4562
|
+
// The identity test is inline because most expressions are unchanged, and reaching
|
|
4563
|
+
// exprSame() only to be told so costs more than the comparison itself.
|
|
4564
|
+
let oldExpr = oldExprs[i], newExpr = newExprs[i];
|
|
4565
|
+
let flag = flags[i];
|
|
4566
|
+
if ((oldExpr !== newExpr && !exprSame(oldExpr, newExpr))
|
|
4567
|
+
|| ((flag & 1) && typeof newExpr === 'boolean')) {
|
|
4568
|
+
// .slice() is required: resolveStampSlots returns the Shell's SHARED scratch
|
|
4569
|
+
// array, which the next row's resolve would overwrite.
|
|
3511
4570
|
if (slots === null)
|
|
3512
|
-
slots = this.resolveStampSlots(shell);
|
|
4571
|
+
slots = this.stampSlotsCache = this.resolveStampSlots(shell).slice();
|
|
3513
4572
|
let stamper = stampers[i];
|
|
3514
|
-
let marker = slots[
|
|
4573
|
+
let marker = slots[slotIdx[i]]; // The flat slot array, so the Path isn't loaded.
|
|
3515
4574
|
|
|
3516
4575
|
// Fast path for a wholeParent text path whose child already exists (the common
|
|
3517
4576
|
// rewrite case): set its value directly, skipping applySingle's branching and
|
|
3518
4577
|
// textNode bookkeeping. exprSame above already proved it changed.
|
|
3519
|
-
if (
|
|
4578
|
+
if (flag & 2) {
|
|
3520
4579
|
let v = newExprs[i], tn = marker.firstChild;
|
|
3521
4580
|
if (typeof v === 'number')
|
|
3522
4581
|
v += '';
|
|
@@ -3551,16 +4610,10 @@ class NodeGroup {
|
|
|
3551
4610
|
* @return {Node[]} The shell's shared scratch slots array. */
|
|
3552
4611
|
resolveStampSlots(shell) {
|
|
3553
4612
|
let slots = shell.resolveSlots;
|
|
4613
|
+
// A singleRoot shell's first op pair is always [0, 0], so slot 1 is the row's own root
|
|
4614
|
+
// element and the program can start at the second pair.
|
|
3554
4615
|
slots[1] = this.startNode;
|
|
3555
|
-
|
|
3556
|
-
// firstChild/nextSibling pointer walk; see setPathsFromFragment for why not childNodes[i].
|
|
3557
|
-
for (let i=2, s=2; i<ops.length; i+=2, s++) {
|
|
3558
|
-
let node = slots[ops[i]].firstChild;
|
|
3559
|
-
for (let k=ops[i+1]; k>0; k--)
|
|
3560
|
-
node = node.nextSibling;
|
|
3561
|
-
slots[s] = node;
|
|
3562
|
-
}
|
|
3563
|
-
return slots;
|
|
4616
|
+
return runResolveOps(slots, shell.resolveOps, 2, 2);
|
|
3564
4617
|
}
|
|
3565
4618
|
|
|
3566
4619
|
/**
|
|
@@ -3570,17 +4623,8 @@ class NodeGroup {
|
|
|
3570
4623
|
* @param shell {?Shell}
|
|
3571
4624
|
* @return {Path[]} */
|
|
3572
4625
|
materializePaths(shell=null) {
|
|
3573
|
-
shell ??=
|
|
3574
|
-
let
|
|
3575
|
-
let paths = shell.paths;
|
|
3576
|
-
let pathLength = paths.length;
|
|
3577
|
-
let result = this.paths = new Array(pathLength);
|
|
3578
|
-
for (let i=0; i<pathLength; i++) {
|
|
3579
|
-
let p = paths[i];
|
|
3580
|
-
let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
|
|
3581
|
-
path.parentNg = this;
|
|
3582
|
-
result[i] = path;
|
|
3583
|
-
}
|
|
4626
|
+
shell ??= this.shell;
|
|
4627
|
+
let result = this.clonePathsFromSlots(shell, this.resolveStampSlots(shell));
|
|
3584
4628
|
|
|
3585
4629
|
// A wholeParent child-node path that stamped a primitive left exactly one Text child.
|
|
3586
4630
|
for (let idx of shell.nodesPathIdx) {
|
|
@@ -3620,14 +4664,8 @@ class NodeGroup {
|
|
|
3620
4664
|
/**
|
|
3621
4665
|
* Get the root element of the NodeGroup's RootNodeGroup.
|
|
3622
4666
|
* @returns {HTMLElement|DocumentFragment} */
|
|
3623
|
-
|
|
3624
|
-
return this.rootNg.
|
|
3625
|
-
}
|
|
3626
|
-
|
|
3627
|
-
/**
|
|
3628
|
-
* @returns {RootNodeGroup} */
|
|
3629
|
-
getRootNodeGroup() {
|
|
3630
|
-
return this.rootNg;
|
|
4667
|
+
getRootEl() {
|
|
4668
|
+
return this.rootNg.rootEl;
|
|
3631
4669
|
}
|
|
3632
4670
|
|
|
3633
4671
|
/**
|
|
@@ -3638,9 +4676,6 @@ class NodeGroup {
|
|
|
3638
4676
|
* @param isRootClone {boolean} True when fragment is a direct clone of a singleRoot
|
|
3639
4677
|
* shell's root element: it fills slot 1 itself and the first op pair is skipped. */
|
|
3640
4678
|
setPathsFromFragment(fragment, shell, startingPathDepth=0, isRootClone=false) {
|
|
3641
|
-
let paths = shell.paths;
|
|
3642
|
-
let pathLength = paths.length; // For faster iteration
|
|
3643
|
-
let result = this.paths = new Array(pathLength);
|
|
3644
4679
|
|
|
3645
4680
|
// Fast path: run the shell's precomputed resolve program (see Shell.buildResolveProgram).
|
|
3646
4681
|
// Each Path.clone() would walk childNodes from the fragment root to its target node,
|
|
@@ -3652,37 +4687,45 @@ class NodeGroup {
|
|
|
3652
4687
|
// attribPaths behavior; pathOffset!==0 (root grafting) also uses the fallback.
|
|
3653
4688
|
let ops = shell.resolveOps;
|
|
3654
4689
|
if (ops && startingPathDepth === 0) {
|
|
3655
|
-
let slots
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
s = 2;
|
|
3661
|
-
}
|
|
3662
|
-
else
|
|
4690
|
+
let slots;
|
|
4691
|
+
if (isRootClone) // The root element is also this.startNode, so it seeds slot 1 itself.
|
|
4692
|
+
slots = this.resolveStampSlots(shell);
|
|
4693
|
+
else {
|
|
4694
|
+
slots = shell.resolveSlots;
|
|
3663
4695
|
slots[0] = fragment;
|
|
3664
|
-
|
|
3665
|
-
// childNodes[index]; the live NodeList indexing is markedly slower, and indices
|
|
3666
|
-
// are small (markers are elements, often the first child after whitespace stripping).
|
|
3667
|
-
for (; i<ops.length; i+=2, s++) {
|
|
3668
|
-
let node = slots[ops[i]].firstChild;
|
|
3669
|
-
for (let k=ops[i+1]; k>0; k--)
|
|
3670
|
-
node = node.nextSibling;
|
|
3671
|
-
slots[s] = node;
|
|
3672
|
-
}
|
|
3673
|
-
for (let i=0; i<pathLength; i++) {
|
|
3674
|
-
let p = paths[i];
|
|
3675
|
-
let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
|
|
3676
|
-
path.parentNg = this;
|
|
3677
|
-
result[i] = path;
|
|
4696
|
+
runResolveOps(slots, ops, 0, 1);
|
|
3678
4697
|
}
|
|
4698
|
+
this.clonePathsFromSlots(shell, slots);
|
|
3679
4699
|
}
|
|
3680
|
-
else
|
|
4700
|
+
else {
|
|
4701
|
+
let paths = shell.paths;
|
|
4702
|
+
let pathLength = paths.length; // For faster iteration
|
|
4703
|
+
let result = this.paths = new Array(pathLength);
|
|
3681
4704
|
for (let i=0; i<pathLength; i++) {
|
|
3682
4705
|
let path = paths[i].clone(fragment, startingPathDepth);
|
|
3683
4706
|
path.parentNg = this;
|
|
3684
4707
|
result[i] = path;
|
|
3685
4708
|
}
|
|
4709
|
+
}
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
/**
|
|
4713
|
+
* Copy the shell's Paths onto this NodeGroup's own nodes, taking each path's marker and
|
|
4714
|
+
* before-node from the slots the resolve program just filled.
|
|
4715
|
+
* @param shell {Shell}
|
|
4716
|
+
* @param slots {Node[]} The shell's shared scratch slots, already resolved.
|
|
4717
|
+
* @return {Path[]} */
|
|
4718
|
+
clonePathsFromSlots(shell, slots) {
|
|
4719
|
+
let paths = shell.paths;
|
|
4720
|
+
let pathLength = paths.length;
|
|
4721
|
+
let result = this.paths = new Array(pathLength);
|
|
4722
|
+
for (let i=0; i<pathLength; i++) {
|
|
4723
|
+
let p = paths[i];
|
|
4724
|
+
let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
|
|
4725
|
+
path.parentNg = this;
|
|
4726
|
+
result[i] = path;
|
|
4727
|
+
}
|
|
4728
|
+
return result;
|
|
3686
4729
|
}
|
|
3687
4730
|
|
|
3688
4731
|
updateStyles() {
|
|
@@ -3690,7 +4733,7 @@ class NodeGroup {
|
|
|
3690
4733
|
for (let [style, oldText] of this.styles) {
|
|
3691
4734
|
let newText = style.textContent;
|
|
3692
4735
|
if (oldText !== newText)
|
|
3693
|
-
Util.bindStyles(style, this.
|
|
4736
|
+
Util.bindStyles(style, this.rootNg.rootEl);
|
|
3694
4737
|
}
|
|
3695
4738
|
}
|
|
3696
4739
|
|
|
@@ -3700,16 +4743,14 @@ class NodeGroup {
|
|
|
3700
4743
|
* @param pathOffset {int} */
|
|
3701
4744
|
activateEmbeds(root, shell, pathOffset=0) {
|
|
3702
4745
|
|
|
3703
|
-
let rootEl = this.rootNg.
|
|
4746
|
+
let rootEl = this.rootNg.rootEl;
|
|
3704
4747
|
if (rootEl) {
|
|
3705
|
-
let options = this.rootNg.
|
|
4748
|
+
let options = this.rootNg.renderOptions;
|
|
3706
4749
|
|
|
3707
4750
|
// ids
|
|
3708
4751
|
if (options?.ids !== false) {
|
|
3709
4752
|
for (let path of shell.ids) {
|
|
3710
|
-
|
|
3711
|
-
path = path.slice(0, -pathOffset);
|
|
3712
|
-
let el = Path.resolve(root, path);
|
|
4753
|
+
let el = Path.resolve(root, path, pathOffset);
|
|
3713
4754
|
Util.bindId(rootEl, el);
|
|
3714
4755
|
}
|
|
3715
4756
|
}
|
|
@@ -3719,11 +4760,8 @@ class NodeGroup {
|
|
|
3719
4760
|
if (shell.styles.length)
|
|
3720
4761
|
this.styles = new Map();
|
|
3721
4762
|
for (let path of shell.styles) {
|
|
3722
|
-
if (pathOffset)
|
|
3723
|
-
path = path.slice(0, -pathOffset);
|
|
3724
|
-
|
|
3725
4763
|
/** @type {HTMLStyleElement} */
|
|
3726
|
-
let style = Path.resolve(root, path);
|
|
4764
|
+
let style = Path.resolve(root, path, pathOffset);
|
|
3727
4765
|
if (rootEl.nodeType === 1) {
|
|
3728
4766
|
Util.bindStyles(style, rootEl);
|
|
3729
4767
|
this.styles.set(style, style.textContent);
|
|
@@ -3734,9 +4772,7 @@ class NodeGroup {
|
|
|
3734
4772
|
// scripts
|
|
3735
4773
|
if (options?.scripts !== false) {
|
|
3736
4774
|
for (let path of shell.scripts) {
|
|
3737
|
-
|
|
3738
|
-
path = path.slice(0, -pathOffset);
|
|
3739
|
-
let script = Path.resolve(root, path);
|
|
4775
|
+
let script = Path.resolve(root, path, pathOffset);
|
|
3740
4776
|
// Indirect eval runs in global scope (correct for a <script> tag) and, unlike a direct
|
|
3741
4777
|
// eval, doesn't force terser to keep every top-level name in the bundle unmangled.
|
|
3742
4778
|
(0, eval)(script.textContent);
|
|
@@ -3752,8 +4788,8 @@ class NodeGroup {
|
|
|
3752
4788
|
* Has these properties not present on NodeGroup, assigned by instantiate():
|
|
3753
4789
|
* They're not declared as fields because subclass field initializers run after the
|
|
3754
4790
|
* super constructor and would overwrite the assigned values.
|
|
3755
|
-
* @property {HTMLElement}
|
|
3756
|
-
* @property {?object}
|
|
4791
|
+
* @property {HTMLElement} rootEl - Root node at the top of the hierarchy.
|
|
4792
|
+
* @property {?object} renderOptions - RenderOptions */
|
|
3757
4793
|
class RootNodeGroup extends NodeGroup {
|
|
3758
4794
|
|
|
3759
4795
|
/**
|
|
@@ -3762,19 +4798,19 @@ class RootNodeGroup extends NodeGroup {
|
|
|
3762
4798
|
* Called by the NodeGroup constructor. */
|
|
3763
4799
|
instantiate(shell, shellFragment, el, options) {
|
|
3764
4800
|
let startingPathDepth = 0;
|
|
3765
|
-
this.
|
|
4801
|
+
this.renderOptions = options;
|
|
3766
4802
|
if (shellFragment instanceof Text) {
|
|
3767
4803
|
if (!el)
|
|
3768
|
-
throw new Error('
|
|
4804
|
+
throw new Error('Text node needs an element.');
|
|
3769
4805
|
|
|
3770
|
-
this.
|
|
4806
|
+
this.rootEl = el;
|
|
3771
4807
|
if (shellFragment.nodeValue.length)
|
|
3772
|
-
this.
|
|
4808
|
+
this.rootEl.append(shellFragment);
|
|
3773
4809
|
}
|
|
3774
4810
|
|
|
3775
4811
|
else {
|
|
3776
4812
|
if (el) {
|
|
3777
|
-
this.
|
|
4813
|
+
this.rootEl = el;
|
|
3778
4814
|
|
|
3779
4815
|
// Save slot
|
|
3780
4816
|
// 1. Globals.currentSlotChildren is set if this is called via PathToComponent.applyComponent() calls render()
|
|
@@ -3786,13 +4822,13 @@ class RootNodeGroup extends NodeGroup {
|
|
|
3786
4822
|
}
|
|
3787
4823
|
|
|
3788
4824
|
// If el should replace the root node of the fragment.
|
|
3789
|
-
if (isReplaceEl(shellFragment, this.
|
|
3790
|
-
this.
|
|
4825
|
+
if (isReplaceEl(shellFragment, this.rootEl.tagName)) {
|
|
4826
|
+
this.rootEl.append(...shellFragment.children[0].childNodes);
|
|
3791
4827
|
|
|
3792
4828
|
// Copy attributes
|
|
3793
4829
|
for (let attrib of shellFragment.children[0].attributes)
|
|
3794
|
-
if (!this.
|
|
3795
|
-
this.
|
|
4830
|
+
if (!this.rootEl.hasAttribute(attrib.name))
|
|
4831
|
+
this.rootEl.setAttribute(attrib.name, attrib.value);
|
|
3796
4832
|
|
|
3797
4833
|
// Go one level deeper into all of shell's paths.
|
|
3798
4834
|
startingPathDepth = 1;
|
|
@@ -3801,7 +4837,7 @@ class RootNodeGroup extends NodeGroup {
|
|
|
3801
4837
|
else {
|
|
3802
4838
|
let isEmpty = shellFragment.childNodes.length === 1 && shellFragment.childNodes[0].nodeType === 3 && shellFragment.childNodes[0].textContent === '';
|
|
3803
4839
|
if (!isEmpty)
|
|
3804
|
-
this.
|
|
4840
|
+
this.rootEl.append(...shellFragment.childNodes);
|
|
3805
4841
|
}
|
|
3806
4842
|
|
|
3807
4843
|
|
|
@@ -3827,34 +4863,26 @@ class RootNodeGroup extends NodeGroup {
|
|
|
3827
4863
|
|
|
3828
4864
|
// Instantiate as a standalone element.
|
|
3829
4865
|
else {
|
|
3830
|
-
|
|
3831
|
-
|
|
4866
|
+
// Trimming the whitespace and comment nodes off both ends leaves a list of exactly
|
|
4867
|
+
// one node only when the fragment has exactly one node worth keeping, which is the
|
|
4868
|
+
// question being asked here.
|
|
4869
|
+
let relevantNodes = Util.trimEmptyNodes(shellFragment.childNodes);
|
|
4870
|
+
let onlyChild = relevantNodes.length === 1 ? relevantNodes[0] : null;
|
|
4871
|
+
this.rootEl = onlyChild || shellFragment; // We return the whole fragment when calling h() with a collection of nodes.
|
|
3832
4872
|
if (onlyChild)
|
|
3833
4873
|
startingPathDepth = 1;
|
|
3834
4874
|
}
|
|
3835
4875
|
|
|
3836
|
-
this.setPathsFromFragment(this.
|
|
3837
|
-
this.activateEmbeds(this.
|
|
4876
|
+
this.setPathsFromFragment(this.rootEl, shell, startingPathDepth);
|
|
4877
|
+
this.activateEmbeds(this.rootEl, shell, startingPathDepth);
|
|
3838
4878
|
}
|
|
3839
|
-
this.startNode = this.endNode = this.
|
|
4879
|
+
this.startNode = this.endNode = this.rootEl;
|
|
3840
4880
|
|
|
3841
|
-
Globals$1.rootNodeGroups.set(this.
|
|
4881
|
+
Globals$1.rootNodeGroups.set(this.rootEl, this);
|
|
3842
4882
|
}
|
|
3843
4883
|
}
|
|
3844
4884
|
|
|
3845
4885
|
|
|
3846
|
-
function getSingleEl(fragment) {
|
|
3847
|
-
let nonempty = [];
|
|
3848
|
-
for (let n of fragment.childNodes) {
|
|
3849
|
-
if (n.nodeType === 1 || n.nodeType === 3 && n.textContent.trim().length) {
|
|
3850
|
-
if (nonempty.length)
|
|
3851
|
-
return null;
|
|
3852
|
-
nonempty.push(n);
|
|
3853
|
-
}
|
|
3854
|
-
}
|
|
3855
|
-
return nonempty[0];
|
|
3856
|
-
}
|
|
3857
|
-
|
|
3858
4886
|
/**
|
|
3859
4887
|
* Does the fragment have one child that's an element matching the tagname of el?
|
|
3860
4888
|
* @param fragment {DocumentFragment}
|
|
@@ -3933,8 +4961,12 @@ class Template {
|
|
|
3933
4961
|
if (!ng) {
|
|
3934
4962
|
ng = new RootNodeGroup(this, null, el, options);
|
|
3935
4963
|
if (!el) // null if it's a standalone elment.
|
|
3936
|
-
el = ng.
|
|
3937
|
-
|
|
4964
|
+
el = ng.getRootEl();
|
|
4965
|
+
|
|
4966
|
+
// RootNodeGroup.instantiate() ends by registering itself under its own rootEl, which
|
|
4967
|
+
// is the element we were given, or -- when we were given none -- the very element
|
|
4968
|
+
// getRootEl() just handed back. Registering it a second time here stored the same
|
|
4969
|
+
// group under the same key.
|
|
3938
4970
|
}
|
|
3939
4971
|
|
|
3940
4972
|
// Make sure the expresion count matches match the Path "hole" count.
|
|
@@ -3949,8 +4981,13 @@ class Template {
|
|
|
3949
4981
|
// If we didn't just create it, we need to render it.
|
|
3950
4982
|
if (this.html?.length === 1 && !this.html[0]) // An empty string.
|
|
3951
4983
|
el.innerHTML = ''; // Fast path for empty component.
|
|
3952
|
-
else
|
|
3953
|
-
|
|
4984
|
+
else {
|
|
4985
|
+
// A component renders the same template every time, so hand over the expressions it
|
|
4986
|
+
// applied last time; paths that can prove an unchanged expression is a no-op skip.
|
|
4987
|
+
let last = ng.template;
|
|
4988
|
+
ng.applyExprs(this.exprs, true, last !== this && last.html === this.html ? last.exprs : null);
|
|
4989
|
+
ng.template = this;
|
|
4990
|
+
}
|
|
3954
4991
|
|
|
3955
4992
|
return el;
|
|
3956
4993
|
}
|
|
@@ -3980,9 +5017,13 @@ class Template {
|
|
|
3980
5017
|
function templatesSame(a, b) {
|
|
3981
5018
|
if (a.html === b.html && a.svgMode === b.svgMode) {
|
|
3982
5019
|
let ae = a.exprs, be = b.exprs;
|
|
3983
|
-
|
|
3984
|
-
|
|
5020
|
+
// Most expressions are identical between renders, so test that here rather than paying
|
|
5021
|
+
// a call into exprSame() to learn it.
|
|
5022
|
+
for (let i=0; i<ae.length; i++) {
|
|
5023
|
+
let x = ae[i], y = be[i];
|
|
5024
|
+
if (x !== y && !exprSame(x, y))
|
|
3985
5025
|
return false;
|
|
5026
|
+
}
|
|
3986
5027
|
return true;
|
|
3987
5028
|
}
|
|
3988
5029
|
|
|
@@ -4088,7 +5129,7 @@ function toEl(arg) {
|
|
|
4088
5129
|
let obj = arg;
|
|
4089
5130
|
|
|
4090
5131
|
if (obj.constructor.name !== 'Object')
|
|
4091
|
-
throw new Error(`
|
|
5132
|
+
throw new Error(`Solarite web component class ${obj.constructor?.name} must extend HTMLElement.`);
|
|
4092
5133
|
|
|
4093
5134
|
// Normal path
|
|
4094
5135
|
if (!Globals$1.objToEl.has(obj)) {
|
|
@@ -4176,7 +5217,11 @@ const renderTemplateKey = Symbol('solariteRender');
|
|
|
4176
5217
|
// Using `arguments` alongside rest params would force the engine to materialize both per call.
|
|
4177
5218
|
const noArg = Symbol();
|
|
4178
5219
|
|
|
4179
|
-
|
|
5220
|
+
// The /** @type {*} */ cast on the default keeps TypeScript from inferring the parameter as
|
|
5221
|
+
// `symbol` from noArg: TS can't parse the closure-style @param type above (function() without
|
|
5222
|
+
// a return type under noImplicitAny), falls back to the default's type, and then flags every
|
|
5223
|
+
// h(this) / h`` call in the codebase as an error. JetBrains reads the @param fine either way.
|
|
5224
|
+
function h(htmlStrings=/** @type {*} */(noArg), ...exprs) {
|
|
4180
5225
|
|
|
4181
5226
|
// 1. Tagged template: h`<div>...</div>`
|
|
4182
5227
|
if (Array.isArray(htmlStrings)) {
|
|
@@ -4229,11 +5274,14 @@ function h(htmlStrings=noArg, ...exprs) {
|
|
|
4229
5274
|
let parent = htmlStrings, options = exprs[0];
|
|
4230
5275
|
|
|
4231
5276
|
// The closure is cached on the element so repeated renders don't recreate it.
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
5277
|
+
// Options are cached with it: they only take effect when the element's
|
|
5278
|
+
// RootNodeGroup is first created, so a later render passing different ones is
|
|
5279
|
+
// ignored either way, and caching regardless of them saves an allocation on every
|
|
5280
|
+
// render of a component that passes an options object — which is how render() is
|
|
5281
|
+
// usually written.
|
|
5282
|
+
let cached = parent[renderTemplateKey];
|
|
5283
|
+
if (cached)
|
|
5284
|
+
return cached;
|
|
4237
5285
|
|
|
4238
5286
|
// Return a tagged template function that applies the tagged template to parent.
|
|
4239
5287
|
let renderTemplate = (htmlStrings, ...exprs) => {
|
|
@@ -4245,8 +5293,7 @@ function h(htmlStrings=noArg, ...exprs) {
|
|
|
4245
5293
|
let template = new Template(htmlStrings, exprs);
|
|
4246
5294
|
return template.render(parent, options);
|
|
4247
5295
|
};
|
|
4248
|
-
|
|
4249
|
-
parent[renderTemplateKey] = renderTemplate;
|
|
5296
|
+
parent[renderTemplateKey] = renderTemplate;
|
|
4250
5297
|
return renderTemplate;
|
|
4251
5298
|
}
|
|
4252
5299
|
}
|
|
@@ -4263,11 +5310,11 @@ function h(htmlStrings=noArg, ...exprs) {
|
|
|
4263
5310
|
// Intercepts the main h(this)`...` function call inside render().
|
|
4264
5311
|
// TODO: This path doesn't handle embeds like data-id="..."
|
|
4265
5312
|
else if (typeof htmlStrings === 'object' && Globals$1.objToEl.has(htmlStrings)) {
|
|
5313
|
+
// The only thing that ever puts an object into objToEl is toEl(), and it rejects anything
|
|
5314
|
+
// that isn't a plain object before it does so, so an object that reaches here has already
|
|
5315
|
+
// been checked and re-checking it can never report anything.
|
|
4266
5316
|
let obj = htmlStrings;
|
|
4267
5317
|
|
|
4268
|
-
if (obj.constructor.name !== 'Object')
|
|
4269
|
-
throw new Error(`Solarate Web Component class ${obj.constructor?.name} must extend HTMLElement.`);
|
|
4270
|
-
|
|
4271
5318
|
// Jsx with h(this, <jsx>)
|
|
4272
5319
|
if (exprs[0] instanceof Template) {
|
|
4273
5320
|
let template = exprs[0];
|
|
@@ -4291,14 +5338,6 @@ function h(htmlStrings=noArg, ...exprs) {
|
|
|
4291
5338
|
throw new Error('h() does not support argument of type: ' + (htmlStrings ? typeof htmlStrings : htmlStrings))
|
|
4292
5339
|
}
|
|
4293
5340
|
|
|
4294
|
-
// h.map caches each item's Template keyed by the item's identity, so a re-render returns
|
|
4295
|
-
// the SAME Template instance for any item whose reference is unchanged. The reconciler's
|
|
4296
|
-
// `ng.template === item` fast path (PathToNodes.applyKeyed/applyDiff) then skips rebuilding
|
|
4297
|
-
// and comparing that row. A WeakMap is used instead of a symbol property so the idiomatic
|
|
4298
|
-
// immutable update `{...item, x}` yields a fresh object that ISN'T in the cache and re-renders;
|
|
4299
|
-
// a symbol property would be copied by spread and silently reuse the stale Template.
|
|
4300
|
-
const mapCache = new WeakMap();
|
|
4301
|
-
|
|
4302
5341
|
/**
|
|
4303
5342
|
* Render a list, reusing each item's DOM for as long as the item is the SAME object.
|
|
4304
5343
|
*
|
|
@@ -4315,37 +5354,73 @@ const mapCache = new WeakMap();
|
|
|
4315
5354
|
*
|
|
4316
5355
|
* ${h.map(this.rows, row => h`<tr key=${row.id}>${row.label}</tr>`)}
|
|
4317
5356
|
*
|
|
5357
|
+
* What comes back is a MappedList, not an array: it carries the items and the callback so
|
|
5358
|
+
* the reconciler can match a row to its item by identity and call the callback only for the
|
|
5359
|
+
* rows it can't match. Put it straight into a template expression, as above; nested inside
|
|
5360
|
+
* an array, or returned from a function, it expands to Templates just the same.
|
|
5361
|
+
*
|
|
4318
5362
|
* @param items {Array} The list to render.
|
|
4319
5363
|
* @param fn {function(item:*):Template} Builds an item's Template; called only for new items.
|
|
4320
|
-
* @return {
|
|
4321
|
-
h.map = (items, fn) =>
|
|
4322
|
-
let result = new Array(items.length);
|
|
4323
|
-
for (let i=0; i<items.length; i++) {
|
|
4324
|
-
let item = items[i];
|
|
4325
|
-
if (item !== null && typeof item === 'object') {
|
|
4326
|
-
let template = mapCache.get(item);
|
|
4327
|
-
if (template === undefined) {
|
|
4328
|
-
template = fn(item);
|
|
4329
|
-
mapCache.set(item, template);
|
|
4330
|
-
}
|
|
4331
|
-
result[i] = template;
|
|
4332
|
-
}
|
|
4333
|
-
else
|
|
4334
|
-
result[i] = fn(item);
|
|
4335
|
-
}
|
|
4336
|
-
return result;
|
|
4337
|
-
};
|
|
5364
|
+
* @return {MappedList} */
|
|
5365
|
+
h.map = (items, fn) => new MappedList(items, fn);
|
|
4338
5366
|
|
|
4339
5367
|
h.immutableMap = h.map;
|
|
4340
5368
|
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
5369
|
+
/**
|
|
5370
|
+
* Create a selection that updates only the rows it affects.
|
|
5371
|
+
*
|
|
5372
|
+
* A highlight that moves from one row of a thousand to another changes two attributes.
|
|
5373
|
+
* Expressing it as ordinary state means calling render() and letting the reconciler walk the
|
|
5374
|
+
* list to rediscover that. A selector writes those two attributes directly instead:
|
|
5375
|
+
*
|
|
5376
|
+
* class Table extends Solarite {
|
|
5377
|
+
* selected = h.selector();
|
|
5378
|
+
*
|
|
5379
|
+
* pick(row) {
|
|
5380
|
+
* this.selected.set(row.id); // no render() call
|
|
5381
|
+
* }
|
|
5382
|
+
*
|
|
5383
|
+
* render() {
|
|
5384
|
+
* h(this)`<tbody>${h.map(this.rows, row =>
|
|
5385
|
+
* h`<tr key=${row.id} class=${this.selected.when(row.id, 'danger')}
|
|
5386
|
+
* onclick=${[this.pick, row]}>${row.label}</tr>`)}</tbody>`;
|
|
5387
|
+
* }
|
|
5388
|
+
* }
|
|
5389
|
+
*
|
|
5390
|
+
* when() must be a whole attribute value, not part of one and not element content, since it
|
|
5391
|
+
* owns that attribute for as long as the row exists. An off value of '' leaves no attribute
|
|
5392
|
+
* behind at all. Selection state lives on the selector, so it survives re-renders, and
|
|
5393
|
+
* set() is safe to call whether or not the rows are currently rendered.
|
|
5394
|
+
*
|
|
5395
|
+
* Two rules follow from how set() finds a row, and both throw a clear error rather than
|
|
5396
|
+
* misbehaving quietly. **The rows must be keyed** — set() locates a row by looking its key
|
|
5397
|
+
* up in the list, so the row template needs a key=${...}. And **the attribute must sit on
|
|
5398
|
+
* the row's own root element**, the same one that carries the key, because that is the
|
|
5399
|
+
* element set() writes. Drawing a row costs nothing either way: when() hands back one of
|
|
5400
|
+
* two shared objects rather than allocating anything per row, so a selector is free to
|
|
5401
|
+
* render over a list of any size and only a change of selection does any work.
|
|
5402
|
+
*
|
|
5403
|
+
* @param key {*} The initially selected key, or null for none.
|
|
5404
|
+
* @return {Selector} */
|
|
5405
|
+
h.selector = (key = null) => new Selector(key);
|
|
5406
|
+
|
|
5407
|
+
/**
|
|
5408
|
+
* Convert an attribute string with the given converter: Number, Boolean, String, Date,
|
|
5409
|
+
* or any function taking the string and returning a value. Boolean is true for any string
|
|
5410
|
+
* except 'false' and '0', so a bare attribute like `<my-timer auto-start>` reads as true.
|
|
5411
|
+
* Date uses new Date(value). No converter returns the string unchanged. */
|
|
5412
|
+
function convertType(value, type) {
|
|
5413
|
+
if (type === Date)
|
|
5414
|
+
return new Date(value);
|
|
5415
|
+
if (type === Boolean)
|
|
5416
|
+
return !['false', '0'].includes(value);
|
|
5417
|
+
// Number and String need no cases of their own: they're plain functions, so the custom
|
|
5418
|
+
// branch below calls them correctly. Date and Boolean are the ones that can't fall through
|
|
5419
|
+
// (Date without `new` returns a string; Boolean('false') is true).
|
|
5420
|
+
if (type) // Number, String, or a custom string=>value function
|
|
5421
|
+
return type(value);
|
|
5422
|
+
return value;
|
|
5423
|
+
}
|
|
4349
5424
|
|
|
4350
5425
|
/**
|
|
4351
5426
|
* Read an element's html attributes onto fields that already exist on the element.
|
|
@@ -4381,16 +5456,8 @@ function assignAttributes(dest, types={}, ignore=[]) {
|
|
|
4381
5456
|
dest[name] = JSON.parse(value.slice(2, -1));
|
|
4382
5457
|
|
|
4383
5458
|
// 2. Cast the string with the converter named in `types`, if any.
|
|
4384
|
-
else if (type
|
|
4385
|
-
dest[name] =
|
|
4386
|
-
else if (type === Boolean)
|
|
4387
|
-
dest[name] = !['false', '0'].includes(value);
|
|
4388
|
-
else if (type === Number)
|
|
4389
|
-
dest[name] = Number(value);
|
|
4390
|
-
else if (type === String)
|
|
4391
|
-
dest[name] = String(value);
|
|
4392
|
-
else if (type) // custom string=>value function
|
|
4393
|
-
dest[name] = type(value);
|
|
5459
|
+
else if (type)
|
|
5460
|
+
dest[name] = convertType(value, type);
|
|
4394
5461
|
|
|
4395
5462
|
// 3. No converter named: assign the raw string. But an empty value over a function/object
|
|
4396
5463
|
// field is just the serialization residue of a template expression (functions render as
|
|
@@ -4440,42 +5507,46 @@ let HTMLElementAutoDefine = new Proxy(HTMLElement, {
|
|
|
4440
5507
|
class Solarite extends HTMLElementAutoDefine {
|
|
4441
5508
|
|
|
4442
5509
|
/**
|
|
4443
|
-
*
|
|
4444
|
-
|
|
5510
|
+
* Fill in and fix up the attribs object a component's constructor receives, so the component
|
|
5511
|
+
* can then copy those values onto its own fields, e.g. with ObjectUtil.assign(this, attribs).
|
|
5512
|
+
*
|
|
5513
|
+
* 1. If attribs is an empty object, fill it with the attributes on the DOM element.
|
|
5514
|
+
* This happens when the browser creates the element from plain html, because then nothing
|
|
5515
|
+
* calls the constructor with arguments. Attribute names convert from dash-case to
|
|
5516
|
+
* camelCase, and `${...}` values are parsed from JSON.
|
|
5517
|
+
* 2. If types is given, convert attribs values from strings to those types. Attribute values
|
|
5518
|
+
* written as literal text always arrive as strings, whether from plain html or from an h()
|
|
5519
|
+
* template. types maps a field name to Number, Boolean, String, Date, or any function
|
|
5520
|
+
* taking the string and returning a value. Boolean is true for every string except
|
|
5521
|
+
* 'false' and '0', so a bare attribute like `<select-box-3 editable>` becomes true.
|
|
5522
|
+
* Values that are already not strings, like a `${true}` template expression, are left alone.
|
|
5523
|
+
*
|
|
5524
|
+
* This runs before the subclass initializes its fields and renders, so converted values are
|
|
5525
|
+
* right the first time, even for fields that change what render() builds. This constructor
|
|
5526
|
+
* can't copy attribs onto fields itself, because subclass field initializers run after it
|
|
5527
|
+
* finishes and would overwrite them; that's why the subclass does the final assign.
|
|
5528
|
+
* @param attribs {?Record<string, any>}
|
|
5529
|
+
* @param types {?Record<string, Function>} */
|
|
5530
|
+
constructor(attribs=null, types=null) {
|
|
4445
5531
|
super();
|
|
4446
5532
|
|
|
4447
5533
|
if (attribs) {
|
|
4448
5534
|
if (typeof attribs !== 'object')
|
|
4449
|
-
throw new Error('First argument
|
|
5535
|
+
throw new Error('First argument must be an object.');
|
|
4450
5536
|
|
|
4451
5537
|
// 1. Populate attribs if it's an empty object.
|
|
4452
|
-
if (
|
|
5538
|
+
if (!Object.keys(attribs).length) {
|
|
4453
5539
|
let attribs2 = Solarite.getAttribs(this);
|
|
4454
5540
|
for (let name in attribs2) {
|
|
4455
5541
|
attribs[name] = attribs2[name];
|
|
4456
5542
|
}
|
|
4457
5543
|
}
|
|
4458
5544
|
|
|
4459
|
-
// 2.
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
// const descriptor = Object.getOwnPropertyDescriptor(this, name);
|
|
4464
|
-
// if (!descriptor || descriptor.writable || descriptor.set)
|
|
4465
|
-
// this[name] = attribs[name];
|
|
4466
|
-
// }
|
|
4467
|
-
//}
|
|
5545
|
+
// 2. Convert string values to the types the component declares.
|
|
5546
|
+
for (let name in types || {})
|
|
5547
|
+
if (typeof attribs[name] === 'string')
|
|
5548
|
+
attribs[name] = convertType(attribs[name], types[name]);
|
|
4468
5549
|
}
|
|
4469
|
-
|
|
4470
|
-
// 3. Wrap render function so it always provides the attribs argument.
|
|
4471
|
-
// Disabled because this gives us strings for attribute values when we call render manually.
|
|
4472
|
-
// Instead of values given from ${...} expressions.
|
|
4473
|
-
// let originalRender = this.render;
|
|
4474
|
-
// this.render = (attribs, changed=true) => {
|
|
4475
|
-
// if (!attribs) // If we have to look up the attribs, we don't know if they changed or not.
|
|
4476
|
-
// attribs = Solarite.getAttribs(this);
|
|
4477
|
-
// originalRender.call(this, attribs, changed);
|
|
4478
|
-
// }
|
|
4479
5550
|
}
|
|
4480
5551
|
|
|
4481
5552
|
'render'() {
|
|
@@ -4618,4 +5689,4 @@ class Solarite extends HTMLElementAutoDefine {
|
|
|
4618
5689
|
}
|
|
4619
5690
|
|
|
4620
5691
|
export default h;
|
|
4621
|
-
export { Fragment, Globals$1 as Globals, Solarite, Util as SolariteUtil, Template, assignAttributes, delve, getEventBinding, h, svg, toEl };
|
|
5692
|
+
export { Fragment, Globals$1 as Globals, JsxAttr as InternalJsxAttr, MappedList, Selector, SelectorRef, Solarite, Util as SolariteUtil, Template, assignAttributes, convertType, delve, getEventBinding, h, jsxToTemplate as internalJsxToTemplate, svg, toEl };
|