solarite 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Solarite-debug.js +2206 -955
- package/dist/Solarite.js +2154 -909
- package/dist/Solarite.min.js +2 -2
- package/dist/jsx-dev-runtime.js +3 -0
- package/dist/jsx-dev-runtime.min.js +3 -0
- package/dist/jsx-runtime.js +86 -0
- package/dist/jsx-runtime.min.js +2 -0
- package/package.json +8 -7
- package/readme.md +9 -9
- package/src/Globals.js +22 -2
- 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 +259 -112
- package/src/PathToAttribs.js +10 -18
- package/src/PathToComponent.js +121 -83
- package/src/PathToEvent.js +32 -12
- package/src/PathToKey.js +0 -6
- package/src/PathToNodes.js +695 -155
- package/src/RootNodeGroup.js +45 -35
- package/src/Selector.js +207 -0
- package/src/Shell.js +313 -123
- package/src/Solarite.d.ts +91 -13
- package/src/Solarite.js +38 -25
- package/src/Template.js +20 -7
- package/src/Util.js +88 -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-debug.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*@__NO_SIDE_EFFECTS__*/
|
|
2
2
|
function assert(val) {
|
|
3
|
-
//#
|
|
3
|
+
//#IFDEBUG
|
|
4
4
|
if (!val) {
|
|
5
5
|
//debugger;
|
|
6
6
|
throw new Error('Assertion failed: ' + val);
|
|
@@ -21,8 +21,17 @@ function reset() {
|
|
|
21
21
|
connected: new WeakSet(),
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* A hand-off in flight from PathToComponent.applyAll(), which parks the child nodes
|
|
25
|
+
* declared inside a component's tag here just before constructing it, to that
|
|
26
|
+
* component's RootNodeGroup.instantiate(), which puts them in its <slot>. Null when
|
|
27
|
+
* no hand-off is pending.
|
|
28
|
+
*
|
|
29
|
+
* It is addressed by Constructor rather than by tag name because a customized
|
|
30
|
+
* built-in has no usable tag at the moment it is consumed: a <tr is="my-row">
|
|
31
|
+
* reports a tagName of TR, and its 'is' attribute is not written until after the
|
|
32
|
+
* constructor -- which may already have rendered -- has returned.
|
|
33
|
+
*
|
|
34
|
+
* @type {?{Constructor:Function, nodes:Node[]}} */
|
|
26
35
|
currentSlotChildren: null,
|
|
27
36
|
|
|
28
37
|
div: document.createElement("div"),
|
|
@@ -62,6 +71,17 @@ function reset() {
|
|
|
62
71
|
}
|
|
63
72
|
reset();
|
|
64
73
|
|
|
74
|
+
// Warn when a second copy of Solarite loads into the same page. Each copy has its own classes and its own Globals,
|
|
75
|
+
// so a template or component made by one is not recognised by the other, and the failure that follows (a template
|
|
76
|
+
// rendered as "[object Object]", a slot that stays empty) gives no hint of the cause. The usual ways to get two are
|
|
77
|
+
// importing both Solarite.js and Solarite.min.js, or a JSX runtime file that doesn't match the build being imported.
|
|
78
|
+
// The marker is the same for every build, so the source, debug, and minified builds all detect one another.
|
|
79
|
+
let copy = Symbol.for('solarite');
|
|
80
|
+
if (globalThis[copy])
|
|
81
|
+
console.warn(`Solarite loaded twice: ${globalThis[copy]} and ${import.meta.url}. Templates and components from one won't work in the other.`);
|
|
82
|
+
else
|
|
83
|
+
globalThis[copy] = import.meta.url;
|
|
84
|
+
|
|
65
85
|
var Globals$1 = Globals;
|
|
66
86
|
|
|
67
87
|
/**
|
|
@@ -121,6 +141,12 @@ function isDelvePath(arr) {
|
|
|
121
141
|
// d means "don't create"
|
|
122
142
|
let d = {};
|
|
123
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Prefix that asks for a handler to bypass event delegation: `<button native:onclick=\${...}>`
|
|
146
|
+
* is bound with addEventListener at render time, taking its normal place in the browser's own
|
|
147
|
+
* dispatch order. Shared by Util.isEvent() and PathToEvent, which strips it. */
|
|
148
|
+
const nativeEventPrefix = 'native:';
|
|
149
|
+
|
|
124
150
|
let Util = {
|
|
125
151
|
|
|
126
152
|
/**
|
|
@@ -160,13 +186,15 @@ let Util = {
|
|
|
160
186
|
// Don't clobber a non-element value. For a simple (non-nested) id this covers two cases:
|
|
161
187
|
// an inherited/built-in property like `title` or `style`, or an own property that already
|
|
162
188
|
// holds a non-Node value. A previously-bound element (a Node) is fine to re-assign.
|
|
189
|
+
// This can only fail on a mistake in the component's own template, so a developer meets it
|
|
190
|
+
// the first time the component renders and never again at runtime. It nonetheless SHIPS,
|
|
191
|
+
// and deliberately: debug-strip blocks are removed from dist/Solarite.js, which is what
|
|
192
|
+
// npm serves, so hiding it there would delete it for everyone, not only for production.
|
|
163
193
|
if (!id.includes('.')) {
|
|
164
194
|
let existing = root[id];
|
|
165
195
|
let isInherited = (id in root) && !Object.hasOwn(root, id);
|
|
166
196
|
if (!existing?.nodeType && (existing != null || isInherited))
|
|
167
|
-
throw new Error(
|
|
168
|
-
`<${el.tagName.toLowerCase()} id="${id}"> because it would clobber an existing ` +
|
|
169
|
-
`${isInherited ? 'built-in ' : ''}property. Rename the id or the property.`);
|
|
197
|
+
throw new Error(`Solarite: id="${id}" would overwrite an existing ${root.constructor.name} property.`);
|
|
170
198
|
}
|
|
171
199
|
|
|
172
200
|
delve(root, id.split(/\./g), el);
|
|
@@ -185,29 +213,29 @@ let Util = {
|
|
|
185
213
|
bindStyles(style, root) {
|
|
186
214
|
|
|
187
215
|
let tagName = root.tagName.toLowerCase();
|
|
188
|
-
|
|
216
|
+
|
|
217
|
+
// A global style is scoped by tag name alone, so it needs no attribute in the selector.
|
|
218
|
+
let attribSelector = '';
|
|
189
219
|
|
|
190
220
|
if (style.hasAttribute('global') || style.hasAttribute('data-global')) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (!doc.head.querySelector(`style[data-style="${styleId}"]`)) {
|
|
195
|
-
doc.head.append(style);
|
|
196
|
-
style.setAttribute('data-style', styleId);
|
|
197
|
-
}
|
|
198
|
-
else // TODO: Make sure the style has no expressions.
|
|
221
|
+
let head = Globals$1.doc.head;
|
|
222
|
+
if (head.querySelector(`style[data-style="${tagName}"]`))
|
|
223
|
+
// TODO: Make sure the style has no expressions.
|
|
199
224
|
style.remove(); // already in the head.
|
|
225
|
+
else {
|
|
226
|
+
head.append(style);
|
|
227
|
+
style.setAttribute('data-style', tagName);
|
|
228
|
+
}
|
|
200
229
|
}
|
|
201
230
|
else {
|
|
202
231
|
let styleId = root.getAttribute('data-style');
|
|
203
232
|
if (!styleId) {
|
|
204
|
-
// Keep track of one style id for each class.
|
|
233
|
+
// Keep track of one style id for each class. Reading the static walks up to a parent
|
|
234
|
+
// class's counter if this class has never been styled, but the assignment always lands
|
|
235
|
+
// on this class, so each class then counts on from where its parent left off.
|
|
205
236
|
// TODO: Put this outside the class in a map, so it doesn't conflict with static properties.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
styleId = root.constructor.styleId++;
|
|
209
|
-
|
|
210
|
-
root.setAttribute('data-style', styleId);
|
|
237
|
+
let Class = root.constructor;
|
|
238
|
+
root.setAttribute('data-style', styleId = Class.styleId = (Class.styleId || 0) + 1);
|
|
211
239
|
}
|
|
212
240
|
|
|
213
241
|
attribSelector = `[data-style="${styleId}"]`;
|
|
@@ -217,7 +245,19 @@ let Util = {
|
|
|
217
245
|
for (let child of style.childNodes) {
|
|
218
246
|
if (child.nodeType === 3) {
|
|
219
247
|
let oldText = child.textContent;
|
|
220
|
-
|
|
248
|
+
|
|
249
|
+
// One pass rewrites both forms of the selector:
|
|
250
|
+
// 1. The functional form ':host(X)' — the host element when it also matches X — unwraps
|
|
251
|
+
// so X sits right after the scoped name: tag[data-style="1"]X. X may hold one
|
|
252
|
+
// nested group like ':not(.open)'; deeper parentheses can't be paired by a regex,
|
|
253
|
+
// so such an X is left as written rather than half-rewritten into a selector the
|
|
254
|
+
// browser would discard silently.
|
|
255
|
+
// 2. Plain ':host'. The lookahead turns down longer names (':host-context') and '(',
|
|
256
|
+
// which only follows ':host' when alternative 1 already gave up on it, and accepts
|
|
257
|
+
// the end of the text node, where an expression may have split a dynamic style.
|
|
258
|
+
let newText = oldText.replace(
|
|
259
|
+
/:host(?:\(((?:[^()]|\([^()]*\))*)\)|(?![-a-z0-9_(]))/gi,
|
|
260
|
+
`${tagName}${attribSelector}$1`);
|
|
221
261
|
if (oldText !== newText)
|
|
222
262
|
child.textContent = newText;
|
|
223
263
|
}
|
|
@@ -238,17 +278,15 @@ let Util = {
|
|
|
238
278
|
* 'UIForm' => 'ui-form'
|
|
239
279
|
* 'A100' => 'a-100' */
|
|
240
280
|
camelToDashes(str) {
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// Convert all the remaining capital letters to lowercase.
|
|
251
|
-
return str.toLowerCase();
|
|
281
|
+
// One pass finds all three dash positions. Each alternative matches only the character
|
|
282
|
+
// *before* the boundary and uses a lookahead for what follows, so the following character
|
|
283
|
+
// is never consumed and can still start the next boundary. That's what lets the three
|
|
284
|
+
// rules interleave in a single scan the way three sequential replaces used to:
|
|
285
|
+
// 1. a lowercase letter or digit before a capital ('ProperName').
|
|
286
|
+
// 2. a capital before a capital+lowercase pair, i.e. the last capital of a run ('HTMLElement').
|
|
287
|
+
// 3. a letter before a digit ('A100').
|
|
288
|
+
// '$&-' appends the dash after the matched character, then everything folds to lowercase.
|
|
289
|
+
return str.replace(/[a-z0-9](?=[A-Z])|[A-Z](?=[A-Z][a-z])|[a-zA-Z](?=\d)/g, '$&-').toLowerCase();
|
|
252
290
|
},
|
|
253
291
|
|
|
254
292
|
/**
|
|
@@ -264,13 +302,24 @@ let Util = {
|
|
|
264
302
|
return str.replace(/-([a-z])/g, g => g[1].toUpperCase());
|
|
265
303
|
},
|
|
266
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Register Class as a custom element, unless it's registered already.
|
|
307
|
+
* @param Class {typeof HTMLElement}
|
|
308
|
+
* @param tagName {?string} Name to register under. Defaults to the dashed form of the class name.
|
|
309
|
+
* @return {string} The tag name Class is registered under, whether we just registered it or it
|
|
310
|
+
* was already in the registry under some other name. Callers that emit markup for the class
|
|
311
|
+
* use this instead of re-deriving the name, which guesses wrong for any class registered
|
|
312
|
+
* under a name that isn't camelToDashes(Class.name). */
|
|
267
313
|
defineClass(Class, tagName) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
314
|
+
let defined = customElements[getName](Class);
|
|
315
|
+
if (defined) // Previously defined.
|
|
316
|
+
return defined;
|
|
317
|
+
|
|
318
|
+
tagName = tagName || Util.camelToDashes(Class.name);
|
|
319
|
+
if (!tagName.includes('-')) // Browsers require that web components always have a dash in the name.
|
|
320
|
+
tagName += '-element';
|
|
321
|
+
customElements[define](tagName, Class);
|
|
322
|
+
return tagName;
|
|
274
323
|
},
|
|
275
324
|
|
|
276
325
|
/**
|
|
@@ -297,8 +346,15 @@ let Util = {
|
|
|
297
346
|
return node.value; // String
|
|
298
347
|
},
|
|
299
348
|
|
|
300
|
-
|
|
301
|
-
|
|
349
|
+
/**
|
|
350
|
+
* True for an attribute name that binds an event: `onclick`, or `native:onclick` for a
|
|
351
|
+
* handler that is registered with addEventListener when the template renders instead of
|
|
352
|
+
* being delegated. Only names an element really exposes as on* handlers count, so an
|
|
353
|
+
* attribute like `online` is never mistaken for one. */
|
|
354
|
+
isEvent(attribName) {
|
|
355
|
+
if (attribName.startsWith(nativeEventPrefix))
|
|
356
|
+
attribName = attribName.slice(nativeEventPrefix.length);
|
|
357
|
+
return attribName.startsWith('on') && attribName in Globals$1.div;
|
|
302
358
|
},
|
|
303
359
|
|
|
304
360
|
/**
|
|
@@ -335,16 +391,13 @@ let Util = {
|
|
|
335
391
|
* @returns {Object} */
|
|
336
392
|
splitAttribs(str) {
|
|
337
393
|
let result = {};
|
|
338
|
-
let attrs = (str + '') // Split string into multiple attributes.
|
|
339
|
-
.split(/([\w-]+\s*=\s*(?:"[^"]*"|'[^']*'|\S+))/g)
|
|
340
|
-
.map(text => text.trim())
|
|
341
|
-
.filter(text => text.length);
|
|
342
394
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
395
|
+
// One scan collects every name and its value. The value is optional so a boolean attribute
|
|
396
|
+
// written on its own ('disabled') still lands in the result with an empty value, and the three
|
|
397
|
+
// value alternatives capture *inside* the quotes so no separate quote-trimming pass is needed.
|
|
398
|
+
// Whatever doesn't look like an attribute name is skipped rather than becoming a bogus key.
|
|
399
|
+
(str + '').replace(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g,
|
|
400
|
+
(_, name, dq, sq, bare) => result[name] = dq ?? sq ?? bare ?? '');
|
|
348
401
|
|
|
349
402
|
return result;
|
|
350
403
|
},
|
|
@@ -382,19 +435,14 @@ let Util = {
|
|
|
382
435
|
* @param nodes {Node[]|NodeList}
|
|
383
436
|
* @returns {Node[]} */
|
|
384
437
|
trimEmptyNodes(nodes) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
// Convert nodeList to an array for easier manipulation
|
|
390
|
-
const result = [...nodes];
|
|
438
|
+
// nodeType 1 is an element and 3 is a text node; the literals are what Node.ELEMENT_NODE
|
|
439
|
+
// and Node.TEXT_NODE are defined as, and they cost a fraction of the bytes.
|
|
440
|
+
let isEmpty = node => node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim());
|
|
391
441
|
|
|
392
|
-
//
|
|
393
|
-
while (result.length
|
|
442
|
+
let result = [...nodes]; // A NodeList can't shift() or pop().
|
|
443
|
+
while (result.length && isEmpty(result[0]))
|
|
394
444
|
result.shift();
|
|
395
|
-
|
|
396
|
-
// Trim from the end
|
|
397
|
-
while (result.length > 0 && shouldTrimNode(result[result.length - 1]))
|
|
445
|
+
while (result.length && isEmpty(result[result.length - 1]))
|
|
398
446
|
result.pop();
|
|
399
447
|
|
|
400
448
|
return result;
|
|
@@ -410,7 +458,7 @@ let getName = 'getName';
|
|
|
410
458
|
|
|
411
459
|
|
|
412
460
|
// For debugging only
|
|
413
|
-
//#
|
|
461
|
+
//#IFDEBUG
|
|
414
462
|
function setIndent$1(items, level=1) {
|
|
415
463
|
if (typeof items === 'string')
|
|
416
464
|
items = items.split(/\r?\n/g);
|
|
@@ -510,6 +558,23 @@ class Path {
|
|
|
510
558
|
* @type {Node[]} Cached result of getNodes() */
|
|
511
559
|
nodesCache;
|
|
512
560
|
|
|
561
|
+
/** @type {boolean|undefined} True when this path provides an attribute of a web component
|
|
562
|
+
* (a -solarite-placeholder element). Only attribute paths ever set it true, but it's
|
|
563
|
+
* declared here on every Path because clone() and cloneWithNodes() copy it to every clone;
|
|
564
|
+
* declaring it keeps those stores from transitioning the clone's hidden class. */
|
|
565
|
+
isComponentAttrib;
|
|
566
|
+
|
|
567
|
+
/** @type {boolean} True when re-applying an expression identical to the one already
|
|
568
|
+
* applied is provably a no-op, so a re-render can skip this path entirely. Only event
|
|
569
|
+
* bindings qualify: binding the same handler to the same node again changes nothing,
|
|
570
|
+
* while an attribute or a child expression may have been altered outside the template. */
|
|
571
|
+
skipIfSame = false;
|
|
572
|
+
|
|
573
|
+
/** @type {boolean|undefined} True when the attribute is a live HTML property
|
|
574
|
+
* (checked/value/selected — Util.isHtmlProp), which users can flip underneath the
|
|
575
|
+
* template. Declared here for the same hidden-class reason as isComponentAttrib. */
|
|
576
|
+
isHtmlProperty;
|
|
577
|
+
|
|
513
578
|
// Set only on Shell paths, never on cloned instances, so they're not declared as
|
|
514
579
|
// class fields; that would cost a store per field on every clone:
|
|
515
580
|
// nodeBeforeIndex {int} Index of nodeBefore among its parentNode's children.
|
|
@@ -523,7 +588,7 @@ class Path {
|
|
|
523
588
|
constructor(nodeBefore, nodeMarker) {
|
|
524
589
|
this.nodeBefore = nodeBefore;
|
|
525
590
|
this.nodeMarker = nodeMarker;
|
|
526
|
-
/*#
|
|
591
|
+
/*#IFDEBUG*/this.verify();/*#ENDIF*/
|
|
527
592
|
}
|
|
528
593
|
|
|
529
594
|
/**
|
|
@@ -545,7 +610,12 @@ class Path {
|
|
|
545
610
|
* [[expr5], [expr6, expr7]] // arguments to second my-component constructor.
|
|
546
611
|
* [expr5] // user attribute value.
|
|
547
612
|
* [expr6, expr7] // role attribute value. */
|
|
548
|
-
|
|
613
|
+
applyAll(exprs) {
|
|
614
|
+
//#IFDEBUG
|
|
615
|
+
assert(Array.isArray(exprs));
|
|
616
|
+
//#ENDIF
|
|
617
|
+
this.applySingle(exprs[0]);
|
|
618
|
+
}
|
|
549
619
|
|
|
550
620
|
/**
|
|
551
621
|
* Fast path used by NodeGroup.applyExprs() when every path consumes exactly one expression.
|
|
@@ -555,26 +625,12 @@ class Path {
|
|
|
555
625
|
|
|
556
626
|
getExpressionCount() { return 1 }
|
|
557
627
|
|
|
558
|
-
|
|
559
628
|
/**
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
let pathLength = path.length - pathOffset;
|
|
566
|
-
for (let i=pathLength-1; i>0; i--) { // Resolve the path.
|
|
567
|
-
//#IFDEV
|
|
568
|
-
assert(root.childNodes[path[i]]);
|
|
569
|
-
//#ENDIF
|
|
570
|
-
root = root.childNodes[path[i]];
|
|
571
|
-
}
|
|
572
|
-
let childNodes = root.childNodes;
|
|
573
|
-
|
|
574
|
-
return pathLength
|
|
575
|
-
? childNodes[path[0]]
|
|
576
|
-
: newRoot;
|
|
577
|
-
}
|
|
629
|
+
* The value a path hands to a component constructor, for the single-expression paths.
|
|
630
|
+
* PathToAttribValue overrides this to join its surrounding static strings.
|
|
631
|
+
* @param exprs {Expr[]}
|
|
632
|
+
* @return {Expr} */
|
|
633
|
+
getValue(exprs) { return exprs[0] }
|
|
578
634
|
|
|
579
635
|
|
|
580
636
|
/**
|
|
@@ -584,7 +640,7 @@ class Path {
|
|
|
584
640
|
* @param nodeMarker {Node}
|
|
585
641
|
* @return {Path} */
|
|
586
642
|
cloneWithNodes(nodeBefore, nodeMarker) {
|
|
587
|
-
let result = new this.constructor(nodeBefore, nodeMarker, this.
|
|
643
|
+
let result = new this.constructor(nodeBefore, nodeMarker, this.attribName, this.attrValue);
|
|
588
644
|
result.isComponentAttrib = this.isComponentAttrib;
|
|
589
645
|
result.wholeParent = this.wholeParent;
|
|
590
646
|
result.isHtmlProperty = this.isHtmlProperty;
|
|
@@ -596,41 +652,25 @@ class Path {
|
|
|
596
652
|
* @param pathOffset {int}
|
|
597
653
|
* @return {Path} */
|
|
598
654
|
clone(newRoot, pathOffset=0) {
|
|
599
|
-
/*#
|
|
600
|
-
|
|
601
|
-
// Resolve node paths.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
let
|
|
606
|
-
|
|
607
|
-
//#IFDEV
|
|
608
|
-
assert(root.childNodes[path[i]]);
|
|
609
|
-
//#ENDIF
|
|
610
|
-
root = root.childNodes[path[i]];
|
|
611
|
-
}
|
|
612
|
-
let childNodes = root.childNodes;
|
|
613
|
-
|
|
614
|
-
nodeMarker = pathLength
|
|
615
|
-
? childNodes[path[0]]
|
|
616
|
-
: newRoot;
|
|
655
|
+
/*#IFDEBUG*/this.verify();/*#ENDIF*/
|
|
656
|
+
|
|
657
|
+
// Resolve node paths. nodeBefore is always a sibling of nodeMarker (Shell builds it from
|
|
658
|
+
// nodeMarker.previousSibling, or inserts a comment immediately before it), so the list
|
|
659
|
+
// nodeBeforeIndex counts within is the marker's own parent's childNodes. An empty path
|
|
660
|
+
// leaves the marker as newRoot itself, and then that list is newRoot's children.
|
|
661
|
+
let nodeBefore;
|
|
662
|
+
let nodeMarker = Path.resolve(newRoot, this.nodeMarkerPath, pathOffset);
|
|
617
663
|
if (this.nodeBefore) {
|
|
618
|
-
|
|
664
|
+
let childNodes = (nodeMarker === newRoot ? newRoot : nodeMarker.parentNode).childNodes;
|
|
665
|
+
//#IFDEBUG
|
|
619
666
|
assert(childNodes[this.nodeBeforeIndex]);
|
|
620
667
|
//#ENDIF
|
|
621
668
|
nodeBefore = childNodes[this.nodeBeforeIndex];
|
|
622
|
-
|
|
623
669
|
}
|
|
624
670
|
|
|
625
|
-
let result =
|
|
671
|
+
let result = this.cloneWithNodes(nodeBefore, nodeMarker);
|
|
626
672
|
|
|
627
|
-
|
|
628
|
-
result.wholeParent = this.wholeParent;
|
|
629
|
-
|
|
630
|
-
// TODO: Put this in PathToAttribValue.clone().
|
|
631
|
-
result.isHtmlProperty = this.isHtmlProperty;
|
|
632
|
-
|
|
633
|
-
//#IFDEV
|
|
673
|
+
//#IFDEBUG
|
|
634
674
|
result.verify();
|
|
635
675
|
//#ENDIF
|
|
636
676
|
|
|
@@ -654,14 +694,21 @@ class Path {
|
|
|
654
694
|
* Note that the path is backward, with the outermost element at the end.
|
|
655
695
|
* @param root {HTMLElement|Document|DocumentFragment|ParentNode}
|
|
656
696
|
* @param path {int[]}
|
|
697
|
+
* @param skip {int} How many of the outermost steps to leave off, for when root is
|
|
698
|
+
* already that many levels down from where the path was recorded. An empty walk
|
|
699
|
+
* (skip === path.length) returns root itself.
|
|
657
700
|
* @returns {Node|HTMLElement|HTMLStyleElement} */
|
|
658
|
-
static resolve(root, path) {
|
|
659
|
-
for (let i=path.length-1; i>=0; i--)
|
|
701
|
+
static resolve(root, path, skip=0) {
|
|
702
|
+
for (let i=path.length-1-skip; i>=0; i--) {
|
|
703
|
+
//#IFDEBUG
|
|
704
|
+
assert(root.childNodes[path[i]]);
|
|
705
|
+
//#ENDIF
|
|
660
706
|
root = root.childNodes[path[i]];
|
|
707
|
+
}
|
|
661
708
|
return root;
|
|
662
709
|
}
|
|
663
710
|
|
|
664
|
-
//#
|
|
711
|
+
//#IFDEBUG
|
|
665
712
|
|
|
666
713
|
/** @return {HTMLElement|ParentNode} */
|
|
667
714
|
getParentNode() {
|
|
@@ -695,122 +742,236 @@ class Path {
|
|
|
695
742
|
//#ENDIF
|
|
696
743
|
}
|
|
697
744
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
745
|
+
/**
|
|
746
|
+
* A key-scoped selection that updates only the rows it actually affects.
|
|
747
|
+
*
|
|
748
|
+
* Rendering a list normally means calling render() and letting the reconciler decide what
|
|
749
|
+
* changed. That is the right default, but it is a poor fit for a selection: moving a
|
|
750
|
+
* highlight from one row of a thousand to another changes two attributes, and asking the
|
|
751
|
+
* reconciler about it means walking the whole list to discover that fact.
|
|
752
|
+
*
|
|
753
|
+
* A Selector short-circuits that. when() hands each row one of exactly two objects — the
|
|
754
|
+
* selected one or the unselected one — and set() reaches the two rows that change through
|
|
755
|
+
* the list they were rendered into, writing their attributes directly with no render() call.
|
|
756
|
+
*
|
|
757
|
+
* This is the same primitive as Solid's createSelector, adapted to a library that has no
|
|
758
|
+
* signals: the list, not a subscription, is what carries the binding.
|
|
759
|
+
*
|
|
760
|
+
* Because set() locates a row by its key, **the rows must be keyed** — the row template needs
|
|
761
|
+
* a key=${...} attribute. set() throws on an unkeyed list rather than silently doing nothing.
|
|
762
|
+
*/
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The value an attribute is bound to. There are only ever **two** of these per Selector,
|
|
766
|
+
* both built in its constructor: one standing for "this row is the selected one" and one for
|
|
767
|
+
* "this row is not". when() returns whichever of the two the row's key calls for.
|
|
768
|
+
*
|
|
769
|
+
* Two singletons rather than one object per key is what makes a selector free to create. A
|
|
770
|
+
* row of a freshly-drawn list with nothing selected gets the unselected singleton, whose
|
|
771
|
+
* value is the off value, so there is no allocation, no map entry and no DOM call — only the
|
|
772
|
+
* two stores that record where the list lives. It also sharpens the re-render skip: a row's
|
|
773
|
+
* expression changes identity exactly when its selectedness changes, so
|
|
774
|
+
* NodeGroup.rewriteStamp() rewrites the rows that gained or lost the selection and no others.
|
|
775
|
+
*/
|
|
776
|
+
class SelectorRef {
|
|
777
|
+
|
|
778
|
+
/** @type {Selector} */
|
|
779
|
+
selector;
|
|
780
|
+
|
|
781
|
+
/** @type {boolean} True on the singleton that stands for the selected row. */
|
|
782
|
+
selected;
|
|
783
|
+
|
|
784
|
+
constructor(selector, selected) {
|
|
785
|
+
this.selector = selector;
|
|
786
|
+
this.selected = selected;
|
|
707
787
|
}
|
|
708
788
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
789
|
+
/** @return {*} The value this ref currently stands for. */
|
|
790
|
+
value() {
|
|
791
|
+
let s = this.selector;
|
|
792
|
+
return this.selected ? s.onValue : s.offValue;
|
|
712
793
|
}
|
|
713
794
|
|
|
714
795
|
/**
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
this.state.buffer = ''; // Reset the buffer when a delimiter or potential self-closing sign is found.
|
|
747
|
-
}
|
|
748
|
-
else if (char === '"' || char === "'" || char === '=') {
|
|
749
|
-
onContextChange?.(html, i, this.state.context, HtmlParser.Attribute);
|
|
750
|
-
this.state.context = HtmlParser.Attribute;
|
|
751
|
-
this.state.quote = char === '=' ? null : char;
|
|
752
|
-
this.state.buffer = '';
|
|
753
|
-
}
|
|
754
|
-
else
|
|
755
|
-
this.state.buffer += char;
|
|
756
|
-
break;
|
|
757
|
-
case HtmlParser.Attribute:
|
|
758
|
-
// Start an attribute quote.
|
|
759
|
-
if (!this.state.quote && !this.state.buffer.length && (char === '"' || char === "'")) {
|
|
760
|
-
this.state.quote = char;
|
|
761
|
-
}
|
|
762
|
-
else if (char === this.state.quote || (!this.state.quote && this.state.buffer.length)) {
|
|
763
|
-
onContextChange?.(html, i, this.state.context, HtmlParser.Tag);
|
|
764
|
-
this.state.context = HtmlParser.Tag;
|
|
765
|
-
this.state.quote = null;
|
|
766
|
-
this.state.buffer = '';
|
|
767
|
-
}
|
|
768
|
-
else if (!this.state.quote && char === '>') {
|
|
769
|
-
onContextChange?.(html, i+1, this.state.context, HtmlParser.Text);
|
|
770
|
-
this.state.context = HtmlParser.Text;
|
|
771
|
-
this.state.quote = null;
|
|
772
|
-
this.state.buffer = '';
|
|
773
|
-
}
|
|
774
|
-
else if (char !== ' ')
|
|
775
|
-
this.state.buffer += char;
|
|
776
|
-
|
|
777
|
-
break;
|
|
778
|
-
}
|
|
796
|
+
* Write this ref's value to an element's attribute, and tell the selector where the list
|
|
797
|
+
* is so that a later set() can find any row in it.
|
|
798
|
+
*
|
|
799
|
+
* Called by PathToAttribValue when the ref appears as an attribute expression. It runs
|
|
800
|
+
* once per row per render, so it is deliberately nothing but two stores and a write that
|
|
801
|
+
* the common case skips.
|
|
802
|
+
*
|
|
803
|
+
* @param node {Node} The element carrying the attribute.
|
|
804
|
+
* @param attribName {string}
|
|
805
|
+
* @param parentNg {NodeGroup} The row this attribute belongs to. */
|
|
806
|
+
bind(node, attribName, parentNg) {
|
|
807
|
+
// set() writes through the row's own root element, so an attribute anywhere deeper
|
|
808
|
+
// would be found at bind time and then written somewhere else at set() time. Catching
|
|
809
|
+
// it here turns a silently misplaced attribute into a clear message. It SHIPS: it is not
|
|
810
|
+
// in a debug-strip block, and it must not be, because the failure it catches is silent.
|
|
811
|
+
if (parentNg.startNode !== node)
|
|
812
|
+
throw new Error(`Solarite: a selector must be on the row's root element.`);
|
|
813
|
+
|
|
814
|
+
let s = this.selector;
|
|
815
|
+
s.attribName = attribName;
|
|
816
|
+
s.path = parentNg.parentPath;
|
|
817
|
+
|
|
818
|
+
let v = this.selected ? s.onValue : s.offValue;
|
|
819
|
+
|
|
820
|
+
// Matches PathToAttribValue.applySingle: an empty or falsy value leaves no attribute
|
|
821
|
+
// behind, so a selector never adds markup a hand-written implementation wouldn't have.
|
|
822
|
+
if (v === '' || v === false || v === null || v === undefined) {
|
|
823
|
+
// A just-cloned row provably carries no attribute of this name yet, so the
|
|
824
|
+
// removeAttribute — a DOM call for every row of the list — can be skipped.
|
|
825
|
+
if (parentNg.firstApply !== true)
|
|
826
|
+
node.removeAttribute(attribName);
|
|
779
827
|
}
|
|
780
|
-
|
|
781
|
-
|
|
828
|
+
else
|
|
829
|
+
node.setAttribute(attribName, v);
|
|
782
830
|
}
|
|
783
831
|
}
|
|
784
832
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
833
|
+
/**
|
|
834
|
+
* Created by h.selector(). Holds one selected key.
|
|
835
|
+
*
|
|
836
|
+
* Only attribute expressions can bind a selector; using one as element content throws,
|
|
837
|
+
* because writing text through this path would need bookkeeping the two-node fast case
|
|
838
|
+
* doesn't want.
|
|
839
|
+
*
|
|
840
|
+
* The selector keeps **no per-row state at all** — no map of keys, nothing to sweep, and
|
|
841
|
+
* nothing that could pin a removed row's element in memory. All it remembers is which
|
|
842
|
+
* attribute it drives and which list it was rendered into.
|
|
843
|
+
*/
|
|
844
|
+
class Selector {
|
|
845
|
+
|
|
846
|
+
/** @type {*} The selected key, or null. */
|
|
847
|
+
#key = null;
|
|
848
|
+
|
|
849
|
+
/** @type {SelectorRef} Returned by when() for the row whose key is selected. */
|
|
850
|
+
#on = new SelectorRef(this, true);
|
|
851
|
+
|
|
852
|
+
/** @type {SelectorRef} Returned by when() for every other row. */
|
|
853
|
+
#off = new SelectorRef(this, false);
|
|
854
|
+
|
|
855
|
+
/** @type {*} Value the bound attribute takes for the selected key. Held here rather than
|
|
856
|
+
* on each ref, so the two refs stay interchangeable between call sites. */
|
|
857
|
+
onValue;
|
|
858
|
+
|
|
859
|
+
/** @type {*} Value it takes for every other key. */
|
|
860
|
+
offValue = '';
|
|
861
|
+
|
|
862
|
+
/** @type {?string} The attribute this selector drives, learned when a row binds. */
|
|
863
|
+
attribName = null;
|
|
864
|
+
|
|
865
|
+
/** @type {?PathToNodes} The list this selector's rows were rendered into, learned when a
|
|
866
|
+
* row binds. set() asks it for the NodeGroup holding a given key. */
|
|
867
|
+
path = null;
|
|
868
|
+
|
|
869
|
+
/** @param key {*} The initially selected key. */
|
|
870
|
+
constructor(key = null) {
|
|
871
|
+
this.#key = key;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** @return {*} The selected key. */
|
|
875
|
+
get key() {
|
|
876
|
+
return this.#key;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Bind an attribute to whether key is the selected one.
|
|
881
|
+
*
|
|
882
|
+
* h`<tr key=${row.id} class=${sel.when(row.id, 'danger')}>`
|
|
883
|
+
*
|
|
884
|
+
* @param key {*} This row's key.
|
|
885
|
+
* @param on {*} Value the attribute takes when key is selected.
|
|
886
|
+
* @param off {*} Value it takes otherwise. '' removes the attribute.
|
|
887
|
+
* @return {SelectorRef} */
|
|
888
|
+
when(key, on, off = '') {
|
|
889
|
+
this.onValue = on;
|
|
890
|
+
this.offValue = off;
|
|
891
|
+
return key === this.#key ? this.#on : this.#off;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Move the selection. Writes at most two attributes — the row losing the selection and
|
|
896
|
+
* the row gaining it — and touches nothing else. There is no render() call.
|
|
897
|
+
* @param key {*} The newly selected key, or null for none. */
|
|
898
|
+
set(key) {
|
|
899
|
+
let old = this.#key;
|
|
900
|
+
if (old === key)
|
|
901
|
+
return;
|
|
902
|
+
this.#key = key;
|
|
903
|
+
|
|
904
|
+
// Nothing has rendered a row yet, so there is no list to write into. The new key
|
|
905
|
+
// still takes effect: rows drawn later come up already carrying the attribute.
|
|
906
|
+
if (this.path === null)
|
|
907
|
+
return;
|
|
908
|
+
|
|
909
|
+
this.#write(old, this.offValue);
|
|
910
|
+
this.#write(key, this.onValue);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Find the row holding key and give its root element the value v.
|
|
915
|
+
* @param key {*}
|
|
916
|
+
* @param v {*} */
|
|
917
|
+
#write(key, v) {
|
|
918
|
+
if (key === null || key === undefined)
|
|
919
|
+
return;
|
|
920
|
+
|
|
921
|
+
let ngs = this.path.nodeGroups;
|
|
922
|
+
if (ngs === null || ngs.length === 0)
|
|
923
|
+
return;
|
|
924
|
+
|
|
925
|
+
if (ngs[0].key === undefined)
|
|
926
|
+
throw new Error('Solarite: a selector must be on a keyed list, as key=${...}.');
|
|
927
|
+
|
|
928
|
+
// A linear scan over the rows. The list is walked only when the selection actually
|
|
929
|
+
// moves — twice per user click, not once per row per render — so a thousand pointer
|
|
930
|
+
// comparisons here cost far less than the per-row index that would avoid them.
|
|
931
|
+
let ng = null;
|
|
932
|
+
for (let i = 0; i < ngs.length; i++)
|
|
933
|
+
if (ngs[i].key === key) {
|
|
934
|
+
ng = ngs[i];
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
if (ng === null)
|
|
938
|
+
return;
|
|
939
|
+
|
|
940
|
+
// The selector owns an attribute on the row's own root element, which for a
|
|
941
|
+
// single-root row template is exactly the NodeGroup's startNode.
|
|
942
|
+
let node = ng.startNode;
|
|
943
|
+
if (node === null || node.nodeType !== 1)
|
|
944
|
+
return;
|
|
945
|
+
|
|
946
|
+
if (v === '' || v === false || v === null || v === undefined)
|
|
947
|
+
node.removeAttribute(this.attribName);
|
|
948
|
+
else
|
|
949
|
+
node.setAttribute(this.attribName, v);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
788
952
|
|
|
789
953
|
class PathToAttribValue extends Path {
|
|
790
954
|
|
|
791
955
|
/** @type {?string} Used only if type=AttribType.Value. */
|
|
792
|
-
|
|
956
|
+
attribName;
|
|
793
957
|
|
|
794
958
|
/**
|
|
795
959
|
* @type {?string[]} Used only if type=AttribType.Value. If null, use one expr to set the whole attribute value. */
|
|
796
960
|
attrValue;
|
|
797
961
|
|
|
798
|
-
|
|
799
|
-
isComponent;
|
|
962
|
+
// isComponentAttrib and isHtmlProperty are declared on the Path base class.
|
|
800
963
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
|
|
964
|
+
constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
|
|
804
965
|
super(null, nodeMarker);
|
|
805
|
-
this.
|
|
966
|
+
this.attribName = attribName;
|
|
806
967
|
this.attrValue = attrValue;
|
|
807
968
|
}
|
|
808
969
|
|
|
809
970
|
/**
|
|
810
971
|
* Set the value of an attribute. This can be for any attribute, not just attributes named "value".
|
|
811
972
|
* @param exprs {Expr[]} */
|
|
812
|
-
|
|
813
|
-
//#
|
|
973
|
+
applyAll(exprs) {
|
|
974
|
+
//#IFDEBUG
|
|
814
975
|
assert(Array.isArray(exprs));
|
|
815
976
|
//#ENDIF
|
|
816
977
|
|
|
@@ -823,14 +984,14 @@ class PathToAttribValue extends Path {
|
|
|
823
984
|
// Only update attributes if the value has changed.
|
|
824
985
|
// This is needed for setting input.value, .checked, option.selected, etc.
|
|
825
986
|
let oldVal = isProp
|
|
826
|
-
? node[this.
|
|
827
|
-
: node.getAttribute(this.
|
|
987
|
+
? node[this.attribName]
|
|
988
|
+
: node.getAttribute(this.attribName);
|
|
828
989
|
if (oldVal !== joinedValue) {
|
|
829
990
|
if (isProp)
|
|
830
|
-
node[this.
|
|
831
|
-
else if (this.
|
|
991
|
+
node[this.attribName] = joinedValue;
|
|
992
|
+
else if (this.attribName === 'value' && node.hasAttribute('contenteditable'))
|
|
832
993
|
node.innerHTML = joinedValue;
|
|
833
|
-
node.setAttribute(this.
|
|
994
|
+
node.setAttribute(this.attribName, joinedValue);
|
|
834
995
|
}
|
|
835
996
|
}
|
|
836
997
|
else
|
|
@@ -843,7 +1004,7 @@ class PathToAttribValue extends Path {
|
|
|
843
1004
|
applySingle(expr) {
|
|
844
1005
|
// One expression surrounded by strings, e.g. class="a ${b} c". Join through apply().
|
|
845
1006
|
if (this.attrValue)
|
|
846
|
-
return this.
|
|
1007
|
+
return this.applyAll([expr]);
|
|
847
1008
|
|
|
848
1009
|
let node = this.nodeMarker;
|
|
849
1010
|
|
|
@@ -864,12 +1025,12 @@ class PathToAttribValue extends Path {
|
|
|
864
1025
|
let [obj, path] = [expr[0], expr.slice(1)];
|
|
865
1026
|
|
|
866
1027
|
if (!obj)
|
|
867
|
-
throw new Error(`Solarite cannot bind
|
|
1028
|
+
throw new Error(`Solarite cannot bind ${this.attribName} to ${obj}.`);
|
|
868
1029
|
|
|
869
1030
|
let value = delve(obj, path);
|
|
870
1031
|
|
|
871
1032
|
// Special case to allow setting select-multiple value from an array
|
|
872
|
-
if (this.
|
|
1033
|
+
if (this.attribName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
|
|
873
1034
|
// Set the .selected property on the options having a value within value.
|
|
874
1035
|
let strValues = value.map(v => v + '');
|
|
875
1036
|
for (let option of node.options)
|
|
@@ -885,7 +1046,7 @@ class PathToAttribValue extends Path {
|
|
|
885
1046
|
const strValue = Util.isFalsy(value) ? '' : value;
|
|
886
1047
|
|
|
887
1048
|
// Special case for contenteditable
|
|
888
|
-
if (this.
|
|
1049
|
+
if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
|
|
889
1050
|
const existingValue = node.innerHTML;
|
|
890
1051
|
if (strValue !== existingValue)
|
|
891
1052
|
node.innerHTML = strValue;
|
|
@@ -894,28 +1055,39 @@ class PathToAttribValue extends Path {
|
|
|
894
1055
|
|
|
895
1056
|
// If we don't have this condition, when we call render(), the browser will scroll to the currently
|
|
896
1057
|
// selected item in a <select> and mess up manually scrolling to a different value.
|
|
897
|
-
if (strValue !== node[this.
|
|
898
|
-
node[this.
|
|
1058
|
+
if (strValue !== node[this.attribName])
|
|
1059
|
+
node[this.attribName] = strValue;
|
|
899
1060
|
}
|
|
900
1061
|
}
|
|
901
1062
|
|
|
902
1063
|
// TODO: We need to remove any old listeners, like in bindEventAttribute.
|
|
903
1064
|
// Does bindEvent() now handle that?
|
|
904
1065
|
let func = () => {
|
|
905
|
-
let value = (this.
|
|
1066
|
+
let value = (this.attribName === 'value' || node.type === 'radio')
|
|
906
1067
|
? Util.getInputValue(node)
|
|
907
|
-
: node[this.
|
|
1068
|
+
: node[this.attribName];
|
|
908
1069
|
delve(obj, path, value);
|
|
909
1070
|
};
|
|
910
1071
|
|
|
911
1072
|
// We use capture so we update the values before other events added by the user.
|
|
912
1073
|
// TODO: Bind to scroll events also?
|
|
913
1074
|
// What about resize events and width/height?
|
|
914
|
-
this.bindEvent(node, this.parentNg.
|
|
1075
|
+
this.bindEvent(node, this.parentNg.getRootEl(), this.attribName, 'input', func, null, true);
|
|
915
1076
|
}
|
|
916
1077
|
|
|
917
1078
|
// Regular attribute
|
|
918
1079
|
else {
|
|
1080
|
+
// A selection binding (h.selector().when()) writes its own value and tells the
|
|
1081
|
+
// selector which list this row belongs to, so a later change of selection reaches
|
|
1082
|
+
// the attribute directly instead of going back through render(). The typeof test
|
|
1083
|
+
// keeps ordinary string attributes — nearly all of them — from paying for the
|
|
1084
|
+
// prototype check.
|
|
1085
|
+
if (typeof expr === 'object' && expr instanceof SelectorRef) {
|
|
1086
|
+
if (!this.isComponentAttrib)
|
|
1087
|
+
expr.bind(node, this.attribName, this.parentNg);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
919
1091
|
// Cache this on Path.isHtmlProperty when Shell creates the props.
|
|
920
1092
|
// Have Path.clone() copy .isHtmlProperty?
|
|
921
1093
|
let isProp = this.isHtmlProperty;
|
|
@@ -928,43 +1100,53 @@ class PathToAttribValue extends Path {
|
|
|
928
1100
|
else
|
|
929
1101
|
expr = Util.makePrimitive(expr);
|
|
930
1102
|
|
|
931
|
-
// Values
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1103
|
+
// Values that remove an attribute. The empty string is included so that an attribute
|
|
1104
|
+
// disappears whenever its expression is empty, instead of only when it happened to be
|
|
1105
|
+
// absent already. makePrimitive() above turns null into '', so plain null lands here
|
|
1106
|
+
// too; the explicit null test still matters for a function expression returning null,
|
|
1107
|
+
// which skips makePrimitive.
|
|
1108
|
+
// An html property is exempt: on those, '' is a real value meaning "empty", as when
|
|
1109
|
+
// clearing an <input>, so it belongs on the assignment path below.
|
|
1110
|
+
if (expr === undefined || expr === false || expr === null || (expr === '' && !isProp)) {
|
|
1111
|
+
if (isProp) {
|
|
1112
|
+
// Clear the property with a value of its own type. Assigning false to a string
|
|
1113
|
+
// property such as input.value would put the text "false" in the field.
|
|
1114
|
+
let old = node[this.attribName];
|
|
1115
|
+
node[this.attribName] = typeof old === 'boolean' ? false : '';
|
|
1116
|
+
}
|
|
1117
|
+
node.removeAttribute(this.attribName);
|
|
936
1118
|
}
|
|
937
1119
|
else if (expr === true) {
|
|
938
1120
|
if (isProp)
|
|
939
|
-
node[this.
|
|
940
|
-
node.setAttribute(this.
|
|
1121
|
+
node[this.attribName] = true;
|
|
1122
|
+
node.setAttribute(this.attribName, '');
|
|
941
1123
|
}
|
|
942
1124
|
|
|
943
1125
|
// A non-toggled attribute
|
|
944
1126
|
else {
|
|
945
1127
|
// Only update attributes if the value has changed.
|
|
946
1128
|
// This is needed for setting input.value, .checked, option.selected, etc.
|
|
947
|
-
//
|
|
1129
|
+
// Non-property attributes never reach here with '', since that removes above.
|
|
948
1130
|
let oldVal = isProp
|
|
949
|
-
? node[this.
|
|
950
|
-
: node.getAttribute(this.
|
|
1131
|
+
? node[this.attribName]
|
|
1132
|
+
: node.getAttribute(this.attribName) ?? '';
|
|
951
1133
|
if (oldVal !== expr) {
|
|
952
1134
|
|
|
953
1135
|
// <textarea value=${expr}></textarea>
|
|
954
1136
|
// Without this branch we have no way to set the value of a textarea,
|
|
955
1137
|
// since we also prohibit expressions that are a child of textarea.
|
|
956
1138
|
if (isProp)
|
|
957
|
-
node[this.
|
|
1139
|
+
node[this.attribName] = expr;
|
|
958
1140
|
|
|
959
1141
|
// Allow one-way binding to contenteditable value attribute.
|
|
960
1142
|
// Contenteditables normally don't have a value attribute and have their content set via innerHTML.
|
|
961
1143
|
// Solarite doesn't allow contenteditables to have expressions as their children.
|
|
962
|
-
else if (this.
|
|
1144
|
+
else if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
|
|
963
1145
|
node.innerHTML = expr;
|
|
964
1146
|
}
|
|
965
1147
|
|
|
966
1148
|
// TODO: Putting an 'else' here would be more performant
|
|
967
|
-
node.setAttribute(this.
|
|
1149
|
+
node.setAttribute(this.attribName, expr);
|
|
968
1150
|
}
|
|
969
1151
|
}
|
|
970
1152
|
}
|
|
@@ -978,14 +1160,14 @@ class PathToAttribValue extends Path {
|
|
|
978
1160
|
* @return {string} The joined values of the expressions, or the first expression if there are no strings. */
|
|
979
1161
|
getValue(exprs) {
|
|
980
1162
|
|
|
981
|
-
//#
|
|
1163
|
+
//#IFDEBUG
|
|
982
1164
|
assert(Array.isArray(exprs));
|
|
983
1165
|
//#ENDIF
|
|
984
1166
|
//if (!Array.isArray(exprs))
|
|
985
1167
|
// return exprs;
|
|
986
1168
|
|
|
987
1169
|
if (!this.attrValue) {// If it's not multiple paths inside a single attribute, return first (and only) expression.
|
|
988
|
-
//#
|
|
1170
|
+
//#IFDEBUG
|
|
989
1171
|
assert(exprs.length === 1);
|
|
990
1172
|
//#ENDIF
|
|
991
1173
|
return exprs[0];
|
|
@@ -996,6 +1178,18 @@ class PathToAttribValue extends Path {
|
|
|
996
1178
|
for (let i = 0; i < values.length; i++) {
|
|
997
1179
|
result.push(values[i]);
|
|
998
1180
|
if (i < values.length - 1) {
|
|
1181
|
+
// A selection binding has to own the whole attribute, because its whole point is
|
|
1182
|
+
// writing that attribute without re-rendering, which it can't do if the rest of
|
|
1183
|
+
// the value comes from expressions it doesn't know about. Whether a selector sits
|
|
1184
|
+
// inside a multi-part attribute is fixed by the shape of the template and never by
|
|
1185
|
+
// the data, so this can only be an authoring mistake, and it always surfaces on the
|
|
1186
|
+
// template's very first render -- exactly like the placement check in
|
|
1187
|
+
// SelectorRef.bind(). That makes it safe to strip from the built file, where the
|
|
1188
|
+
// throw is the only thing lost: makePrimitive() then turns the ref into '' and the
|
|
1189
|
+
// attribute is written from its constant parts alone. Stripping it also keeps a
|
|
1190
|
+
// per-expression instanceof out of the multi-part attribute loop.
|
|
1191
|
+
if (typeof exprs[i] === 'object' && exprs[i] instanceof SelectorRef)
|
|
1192
|
+
throw new Error(`Solarite: a selector must own the whole ${this.attribName} attribute.`);
|
|
999
1193
|
let val = Util.makePrimitive(exprs[i]);
|
|
1000
1194
|
if (!Util.isFalsy(val))
|
|
1001
1195
|
result.push(val);
|
|
@@ -1016,17 +1210,44 @@ class PathToAttribValue extends Path {
|
|
|
1016
1210
|
/**
|
|
1017
1211
|
* @param funcAndArgs {?Array} The [func, ...args] array from the template, or null if func stands alone. */
|
|
1018
1212
|
bindEvent(node, root, key, eventName, func, funcAndArgs, capture=false) {
|
|
1213
|
+
//#IFDEBUG
|
|
1214
|
+
// Both callers already guarantee a function, so this only catches a future third caller.
|
|
1215
|
+
// PathToEvent.applySingle() rejects every shape a template can produce and names the
|
|
1216
|
+
// offending value, and the two-way binding path above passes a closure it just made
|
|
1217
|
+
// here, so nothing a page author writes can reach this line. That makes it dev-only:
|
|
1218
|
+
// stripping it from the built file costs no diagnostic that the surviving throw in
|
|
1219
|
+
// PathToEvent doesn't already give, with a better message.
|
|
1019
1220
|
if (typeof func !== 'function')
|
|
1020
|
-
throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.
|
|
1221
|
+
throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attribName}=\${${func}}> because it's not a function.`);
|
|
1222
|
+
//#ENDIF
|
|
1021
1223
|
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
|
|
1224
|
+
// Delegated path: a bubbling event (when the root's options allow it, the default)
|
|
1225
|
+
// stores its handler directly on the node as a per-event-type Symbol expando, with no
|
|
1226
|
+
// EventBinding object and no addEventListener call. When an event of that type
|
|
1227
|
+
// starts, jitDispatcher() attaches a real listener to each node on its path that
|
|
1228
|
+
// carries the expando, so the browser runs the handler at the node's own turn.
|
|
1229
|
+
// Re-renders just overwrite the property. this.delegatedKey is set by the PathToEvent
|
|
1230
|
+
// constructor only for delegatable event names, so this test also excludes
|
|
1231
|
+
// non-bubbling events and native:on* bindings.
|
|
1232
|
+
if (capture === false && this.delegatedKey !== undefined) {
|
|
1233
|
+
let opt = this.parentNg.rootNg.renderOptions?.eventDelegation ?? true;
|
|
1234
|
+
// true delegates everything, an array only the events it names, and any other
|
|
1235
|
+
// value (such as the retired 'document' string) counts as true.
|
|
1236
|
+
if (opt !== false && (!Array.isArray(opt) || opt.includes(eventName))) {
|
|
1237
|
+
let dk = this.delegatedKey;
|
|
1238
|
+
if (node[dk] === undefined) // First binding of this type on this node.
|
|
1239
|
+
ensureDelegatedDispatcher(root, eventName);
|
|
1240
|
+
// Array-form bindings (onclick=${[fn, arg]}, the hot per-row case) store the
|
|
1241
|
+
// template's own [func, ...args] array; a plain function is stored bare.
|
|
1242
|
+
// Either way, nothing is allocated.
|
|
1243
|
+
node[dk] = funcAndArgs || func;
|
|
1244
|
+
node[delegatedRootKey] = root;
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1026
1248
|
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
// a plain function allocates a one-element array, which is rare (buttons, two-way).
|
|
1249
|
+
// Direct path: capture bindings, non-bubbling events, and eventDelegation:false.
|
|
1250
|
+
// Store the callable as a single [func, ...args] array.
|
|
1030
1251
|
let args = funcAndArgs || [func];
|
|
1031
1252
|
|
|
1032
1253
|
// One stable EventBinding object per node+key is registered with addEventListener
|
|
@@ -1036,7 +1257,7 @@ class PathToAttribValue extends Path {
|
|
|
1036
1257
|
let nodeEvents = node[eventBindingsKey];
|
|
1037
1258
|
if (nodeEvents === undefined) {
|
|
1038
1259
|
let b = node[eventBindingsKey] = new EventBinding(root, node, key, args);
|
|
1039
|
-
|
|
1260
|
+
node.addEventListener(eventName, b, capture);
|
|
1040
1261
|
return;
|
|
1041
1262
|
}
|
|
1042
1263
|
|
|
@@ -1054,7 +1275,7 @@ class PathToAttribValue extends Path {
|
|
|
1054
1275
|
let map = node[eventBindingsKey] = {};
|
|
1055
1276
|
map[nodeEvents.key] = nodeEvents;
|
|
1056
1277
|
binding = map[key] = new EventBinding(root, node, key, args);
|
|
1057
|
-
|
|
1278
|
+
node.addEventListener(eventName, binding, capture);
|
|
1058
1279
|
return;
|
|
1059
1280
|
}
|
|
1060
1281
|
}
|
|
@@ -1062,11 +1283,11 @@ class PathToAttribValue extends Path {
|
|
|
1062
1283
|
binding = nodeEvents[key];
|
|
1063
1284
|
if (!binding) {
|
|
1064
1285
|
binding = nodeEvents[key] = new EventBinding(root, node, key, args);
|
|
1065
|
-
|
|
1286
|
+
node.addEventListener(eventName, binding, capture);
|
|
1066
1287
|
return;
|
|
1067
1288
|
}
|
|
1068
1289
|
}
|
|
1069
|
-
binding.
|
|
1290
|
+
binding.rootEl = root;
|
|
1070
1291
|
binding.args = args;
|
|
1071
1292
|
}
|
|
1072
1293
|
}
|
|
@@ -1087,82 +1308,171 @@ function getEventBinding(node, key) {
|
|
|
1087
1308
|
return b instanceof EventBinding ? (b.key === key ? b : undefined) : b[key];
|
|
1088
1309
|
}
|
|
1089
1310
|
|
|
1090
|
-
|
|
1091
|
-
* Attach a new EventBinding either directly or through the root component's delegated
|
|
1092
|
-
* dispatcher. The dispatcher lives on the root element (not the document) so a component
|
|
1093
|
-
* still receives delegated events while detached from the document, and events stay scoped
|
|
1094
|
-
* to the component that rendered them. */
|
|
1095
|
-
function registerBinding(binding, node, eventName, capture, options, root) {
|
|
1096
|
-
// Bubbling events are delegated by default: they skip addEventListener entirely, and one
|
|
1097
|
-
// root-level dispatcher per event type finds bindings by walking up from the event target.
|
|
1098
|
-
// eventDelegation:false opts out; an array delegates only the named events. Capture
|
|
1099
|
-
// bindings and non-bubbling events always stay direct.
|
|
1100
|
-
let delegate = false;
|
|
1101
|
-
if (capture === false) {
|
|
1102
|
-
let opt = options?.eventDelegation ?? true;
|
|
1103
|
-
if (opt !== false && delegatableEvents.has(eventName))
|
|
1104
|
-
delegate = opt === true || opt.includes(eventName);
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
if (delegate) {
|
|
1108
|
-
binding.delegated = true;
|
|
1109
|
-
let types = root[delegatedTypesKey];
|
|
1110
|
-
if (types === undefined)
|
|
1111
|
-
types = root[delegatedTypesKey] = new Set();
|
|
1112
|
-
if (!types.has(eventName)) {
|
|
1113
|
-
types.add(eventName);
|
|
1114
|
-
root.addEventListener(eventName, delegatedDispatcher);
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
else
|
|
1118
|
-
node.addEventListener(eventName, binding, capture);
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
// Bubbling events that one root-level listener can dispatch. Same set Solid.js delegates.
|
|
1311
|
+
// Bubbling events the just-in-time dispatcher handles. Same set Solid.js delegates.
|
|
1122
1312
|
const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
|
|
1123
1313
|
'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
|
|
1124
1314
|
'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
|
|
1125
1315
|
|
|
1126
|
-
//
|
|
1316
|
+
// One Symbol per delegated event type; nodes store their delegated handler under it.
|
|
1317
|
+
// Symbols (vs string expandos like Solid's $$click) can't collide with user properties.
|
|
1318
|
+
const delegatedKeys = {};
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Get the per-event-type Symbol key, or undefined for non-delegatable events.
|
|
1322
|
+
* Called once per PathToEvent construction, never per bind.
|
|
1323
|
+
* @param eventName {string}
|
|
1324
|
+
* @return {symbol|undefined} */
|
|
1325
|
+
function delegatedKeyFor(eventName) {
|
|
1326
|
+
if (!delegatableEvents.has(eventName))
|
|
1327
|
+
return undefined;
|
|
1328
|
+
return delegatedKeys[eventName] ??= Symbol('sol$' + eventName);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// The component root a node's delegated handlers run with as `this`.
|
|
1332
|
+
// Exported so NodeGroup.applyStamp()'s compiled stamp program can write it directly.
|
|
1333
|
+
const delegatedRootKey = Symbol('solariteDelegatedRoot');
|
|
1334
|
+
|
|
1335
|
+
// Set of event types that already have the dispatcher registered, kept on each root element
|
|
1336
|
+
// and on each document.
|
|
1127
1337
|
const delegatedTypesKey = Symbol('solariteDelegatedTypes');
|
|
1128
1338
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1339
|
+
/**
|
|
1340
|
+
* Register the just-in-time dispatcher for eventName on root and on root's document, once
|
|
1341
|
+
* each. Shared by bindEvent()'s delegated branch and NodeGroup.applyStamp()'s stamp program.
|
|
1342
|
+
*
|
|
1343
|
+
* Both registrations are needed. The document's listener is what still reaches a bound node
|
|
1344
|
+
* after another component re-parents it outside its root (a toolbar a dock parks in its own
|
|
1345
|
+
* chrome). The root's listener is what reaches what the document cannot see: a component
|
|
1346
|
+
* that isn't in the document at all, nodes inside a closed shadow root, and a synthetic
|
|
1347
|
+
* event dispatched inside any shadow root without composed:true, which never leaves it.
|
|
1348
|
+
* @param root {HTMLElement}
|
|
1349
|
+
* @param eventName {string} */
|
|
1350
|
+
function ensureDelegatedDispatcher(root, eventName) {
|
|
1351
|
+
let types = root[delegatedTypesKey];
|
|
1352
|
+
if (types === undefined)
|
|
1353
|
+
types = root[delegatedTypesKey] = new Set();
|
|
1354
|
+
if (!types.has(eventName)) {
|
|
1355
|
+
types.add(eventName);
|
|
1356
|
+
root.addEventListener(eventName, jitDispatcher, true);
|
|
1357
|
+
|
|
1358
|
+
let doc = root.ownerDocument;
|
|
1359
|
+
let docTypes = doc[delegatedTypesKey];
|
|
1360
|
+
if (docTypes === undefined)
|
|
1361
|
+
docTypes = doc[delegatedTypesKey] = new Set();
|
|
1362
|
+
if (!docTypes.has(eventName)) {
|
|
1363
|
+
docTypes.add(eventName);
|
|
1364
|
+
doc.addEventListener(eventName, jitDispatcher, true);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Set on an event by the first dispatcher to walk it, holding the length of the path it saw,
|
|
1370
|
+
// so the dispatchers on nested roots further down don't repeat the walk. A root inside a
|
|
1371
|
+
// closed shadow root sees a longer path than the document did, because composedPath() hides
|
|
1372
|
+
// a closed tree from listeners outside it, and that mismatch is what makes it walk again.
|
|
1131
1373
|
const delegatedDoneKey = Symbol('solariteDelegated');
|
|
1132
1374
|
|
|
1133
1375
|
/**
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1376
|
+
* One shared bubble-phase listener per event type, attached to a node only for the duration
|
|
1377
|
+
* of one event. The browser invokes it at the node's own turn in propagation, and it reads
|
|
1378
|
+
* the node's handler THEN rather than when it was attached, so a handler that an earlier
|
|
1379
|
+
* listener in the same dispatch replaced or removed is honored.
|
|
1380
|
+
* @type {Object<string, {handleEvent: function(Event)}>} */
|
|
1381
|
+
const trampolines = {};
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* @param type {string}
|
|
1385
|
+
* @return {{handleEvent: function(Event)}} */
|
|
1386
|
+
function trampolineFor(type) {
|
|
1387
|
+
let tramp = trampolines[type];
|
|
1388
|
+
if (tramp === undefined) {
|
|
1389
|
+
let dk = delegatedKeys[type];
|
|
1390
|
+
tramp = trampolines[type] = {
|
|
1391
|
+
// Quoted so the minifier's property mangling doesn't rename it, since the browser looks it up by name.
|
|
1392
|
+
'handleEvent'(ev) {
|
|
1393
|
+
let node = ev.currentTarget;
|
|
1394
|
+
let a = node[dk];
|
|
1395
|
+
if (a === undefined) // Unbound by an earlier handler in this same dispatch.
|
|
1396
|
+
return;
|
|
1397
|
+
let root = node[delegatedRootKey];
|
|
1398
|
+
if (typeof a === 'function')
|
|
1399
|
+
a.call(root, ev, node);
|
|
1400
|
+
else
|
|
1401
|
+
switch (a.length) {
|
|
1402
|
+
case 1: a[0].call(root, ev, node); break;
|
|
1403
|
+
case 2: a[0].call(root, a[1], ev, node); break;
|
|
1404
|
+
case 3: a[0].call(root, a[1], a[2], ev, node); break;
|
|
1405
|
+
default: a[0].call(root, ...a.slice(1), ev, node);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
return tramp;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// Nodes still carrying a trampoline, per event type, and the one timer that clears them.
|
|
1414
|
+
const pending = {};
|
|
1415
|
+
let sweepTimer = 0;
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* Remove every trampoline attached since the last sweep. Runs as a task, which is always
|
|
1419
|
+
* after every dispatch in progress has finished. A microtask would not be: for a real click
|
|
1420
|
+
* the browser runs a microtask checkpoint between listeners, so a microtask sweep would strip
|
|
1421
|
+
* the trampolines before the event reached the first of them. The sweep is housekeeping
|
|
1422
|
+
* only; a trampoline left in place is harmless, because jitDispatcher() re-attaches it and
|
|
1423
|
+
* the trampoline reads its handler fresh. */
|
|
1424
|
+
function sweep() {
|
|
1425
|
+
sweepTimer = 0;
|
|
1426
|
+
for (let type in pending) {
|
|
1427
|
+
let nodes = pending[type];
|
|
1428
|
+
if (nodes.length !== 0) {
|
|
1429
|
+
pending[type] = [];
|
|
1430
|
+
let tramp = trampolines[type];
|
|
1431
|
+
for (let i=0; i<nodes.length; i++)
|
|
1432
|
+
nodes[i].removeEventListener(type, tramp);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* The capture-phase listener registered per delegated event type on every root and on the
|
|
1439
|
+
* document. It runs before the event reaches anything, walks the event's path, and attaches
|
|
1440
|
+
* the type's trampoline to each node holding a delegated handler. The browser then finishes
|
|
1441
|
+
* the dispatch natively, so those handlers interleave correctly with listeners anyone else
|
|
1442
|
+
* registered, stopPropagation() works in both directions, currentTarget is right, and the
|
|
1443
|
+
* event needn't bubble.
|
|
1444
|
+
*
|
|
1445
|
+
* Each attach removes the trampoline first. One left from an earlier event in this same task
|
|
1446
|
+
* would otherwise keep its old place in the node's listener list, ahead of listeners added
|
|
1447
|
+
* since; removing and re-adding puts it last, so the rule holds without exception: a
|
|
1448
|
+
* delegated handler runs after every listener its element had when the event started. */
|
|
1449
|
+
function jitDispatcher(ev) {
|
|
1450
|
+
let path = ev.composedPath();
|
|
1451
|
+
if (ev[delegatedDoneKey] === path.length)
|
|
1143
1452
|
return;
|
|
1144
|
-
ev[delegatedDoneKey] =
|
|
1453
|
+
ev[delegatedDoneKey] = path.length;
|
|
1454
|
+
|
|
1145
1455
|
let type = ev.type;
|
|
1146
|
-
let
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1456
|
+
let dk = delegatedKeys[type];
|
|
1457
|
+
let tramp = trampolineFor(type);
|
|
1458
|
+
let list = pending[type];
|
|
1459
|
+
if (list === undefined)
|
|
1460
|
+
list = pending[type] = [];
|
|
1461
|
+
for (let i=0; i<path.length; i++) {
|
|
1462
|
+
let node = path[i];
|
|
1463
|
+
if (node[dk] !== undefined) {
|
|
1464
|
+
node.removeEventListener(type, tramp);
|
|
1465
|
+
node.addEventListener(type, tramp);
|
|
1466
|
+
list.push(node);
|
|
1157
1467
|
}
|
|
1158
|
-
current = current.parentNode;
|
|
1159
1468
|
}
|
|
1160
|
-
|
|
1469
|
+
if (list.length !== 0 && sweepTimer === 0)
|
|
1470
|
+
sweepTimer = setTimeout(sweep);
|
|
1161
1471
|
}
|
|
1162
1472
|
|
|
1163
1473
|
class EventBinding {
|
|
1164
1474
|
constructor(root, node, key, args) {
|
|
1165
|
-
this.
|
|
1475
|
+
this.rootEl = root;
|
|
1166
1476
|
this.node = node;
|
|
1167
1477
|
this.key = key;
|
|
1168
1478
|
|
|
@@ -1176,23 +1486,42 @@ class EventBinding {
|
|
|
1176
1486
|
'handleEvent'(event) {
|
|
1177
1487
|
let a = this.args;
|
|
1178
1488
|
switch (a.length) {
|
|
1179
|
-
case 1: return a[0].call(this.
|
|
1180
|
-
case 2: return a[0].call(this.
|
|
1181
|
-
case 3: return a[0].call(this.
|
|
1489
|
+
case 1: return a[0].call(this.rootEl, event, this.node);
|
|
1490
|
+
case 2: return a[0].call(this.rootEl, a[1], event, this.node);
|
|
1491
|
+
case 3: return a[0].call(this.rootEl, a[1], a[2], event, this.node);
|
|
1182
1492
|
}
|
|
1183
|
-
return a[0].call(this.
|
|
1493
|
+
return a[0].call(this.rootEl, ...a.slice(1), event, this.node);
|
|
1184
1494
|
}
|
|
1185
1495
|
}
|
|
1186
1496
|
|
|
1187
1497
|
// TODO: Merge this into PathToAttribValue?
|
|
1188
1498
|
class PathToEvent extends PathToAttribValue {
|
|
1189
1499
|
|
|
1190
|
-
/** @type {string} The
|
|
1500
|
+
/** @type {string} The attribName without the "on" prefix. */
|
|
1191
1501
|
eventName;
|
|
1192
1502
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1503
|
+
/** @type {symbol|undefined} Expando key nodes store this event's delegated handler under.
|
|
1504
|
+
* Undefined for non-delegatable (non-bubbling) events; bindEvent() then binds directly. */
|
|
1505
|
+
delegatedKey;
|
|
1506
|
+
|
|
1507
|
+
/** @type {boolean} True for `native:onclick`: the handler is registered with addEventListener
|
|
1508
|
+
* when the template renders, so it runs at its element's own turn in the browser's dispatch
|
|
1509
|
+
* order instead of being delegated to the component root. */
|
|
1510
|
+
native;
|
|
1511
|
+
|
|
1512
|
+
constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
|
|
1513
|
+
super(null, nodeMarker, attribName, attrValue);
|
|
1514
|
+
this.skipIfSame = true;
|
|
1515
|
+
let name = attribName;
|
|
1516
|
+
this.native = name !== null && name.startsWith(nativeEventPrefix);
|
|
1517
|
+
if (this.native)
|
|
1518
|
+
name = name.slice(nativeEventPrefix.length);
|
|
1519
|
+
this.eventName = name ? name.slice(2) : null;
|
|
1520
|
+
|
|
1521
|
+
// A native binding leaves delegatedKey undefined. That is the single switch both
|
|
1522
|
+
// bindEvent() and the compiled stamp program test to choose the direct
|
|
1523
|
+
// addEventListener path, so nothing else has to know about the prefix.
|
|
1524
|
+
this.delegatedKey = (this.eventName !== null && !this.native) ? delegatedKeyFor(this.eventName) : undefined;
|
|
1196
1525
|
}
|
|
1197
1526
|
|
|
1198
1527
|
/**
|
|
@@ -1202,8 +1531,8 @@ class PathToEvent extends PathToAttribValue {
|
|
|
1202
1531
|
* onclick=${[this, 'doSomething', 'meow']}
|
|
1203
1532
|
*
|
|
1204
1533
|
* @param exprs {Expr[]} Only the first is used.*/
|
|
1205
|
-
|
|
1206
|
-
//#
|
|
1534
|
+
applyAll(exprs) {
|
|
1535
|
+
//#IFDEBUG
|
|
1207
1536
|
assert(Array.isArray(exprs));
|
|
1208
1537
|
//#ENDIF
|
|
1209
1538
|
|
|
@@ -1211,7 +1540,7 @@ class PathToEvent extends PathToAttribValue {
|
|
|
1211
1540
|
// We have expressions within a string attribute value that's not a Solarite event. E.g.
|
|
1212
1541
|
// <div onclick="alert(${1});"
|
|
1213
1542
|
if (this.attrValue?.length > 1) {
|
|
1214
|
-
super.
|
|
1543
|
+
super.applyAll(exprs);
|
|
1215
1544
|
return;
|
|
1216
1545
|
}
|
|
1217
1546
|
|
|
@@ -1223,16 +1552,16 @@ class PathToEvent extends PathToAttribValue {
|
|
|
1223
1552
|
applySingle(expr) {
|
|
1224
1553
|
// Expressions within a string attribute value that's not a Solarite event.
|
|
1225
1554
|
if (this.attrValue?.length > 1)
|
|
1226
|
-
return super.
|
|
1555
|
+
return super.applyAll([expr]);
|
|
1227
1556
|
|
|
1228
1557
|
// Don't bind events to component placeholders.
|
|
1229
1558
|
// PathToComponent will do the binding later when it instantiates the component.
|
|
1230
1559
|
if (this.isComponentAttrib && this.nodeMarker.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
|
|
1231
1560
|
return;
|
|
1232
1561
|
|
|
1233
|
-
let root = this.parentNg.rootNg.
|
|
1562
|
+
let root = this.parentNg.rootNg.rootEl;
|
|
1234
1563
|
|
|
1235
|
-
/*#
|
|
1564
|
+
/*#IFDEBUG*/
|
|
1236
1565
|
assert(root?.nodeType === 1);
|
|
1237
1566
|
/*#ENDIF*/
|
|
1238
1567
|
|
|
@@ -1250,7 +1579,7 @@ class PathToEvent extends PathToAttribValue {
|
|
|
1250
1579
|
expr = null;
|
|
1251
1580
|
}
|
|
1252
1581
|
else
|
|
1253
|
-
throw new Error(`
|
|
1582
|
+
throw new Error(`Solarite: ${this.attribName}=\${...} is not a function.`);
|
|
1254
1583
|
|
|
1255
1584
|
this.bindEvent(node, root, eventName, eventName, func, expr);
|
|
1256
1585
|
}
|
|
@@ -1373,13 +1702,10 @@ function jsxToTemplate(tag, props, children=[], key=undefined) {
|
|
|
1373
1702
|
|
|
1374
1703
|
// 2a. Custom element class => emit <tag-name ...props>children</tag-name>; PathToComponent
|
|
1375
1704
|
// instantiates it exactly like a tagged-template component.
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
tagName += '-element';
|
|
1381
|
-
return buildIntrinsic(tagName, props, children, key);
|
|
1382
|
-
}
|
|
1705
|
+
// defineClass() hands back the name it registered, or the name the class was already
|
|
1706
|
+
// registered under, so we never have to guess it a second time.
|
|
1707
|
+
if (tag.prototype instanceof HTMLElement)
|
|
1708
|
+
return buildIntrinsic(Util.defineClass(tag), props, children, key);
|
|
1383
1709
|
|
|
1384
1710
|
// 2b. Plain function component: call it with props (+ children) and expect a Template back.
|
|
1385
1711
|
let p = {};
|
|
@@ -1457,24 +1783,21 @@ class PathToAttribs extends Path {
|
|
|
1457
1783
|
* @type {Set<string>} Used for type=AttribType.Multiple to remember the attributes that were added. */
|
|
1458
1784
|
attrNames;
|
|
1459
1785
|
|
|
1460
|
-
/** @type {
|
|
1461
|
-
|
|
1786
|
+
/** @type {PathToEvent|PathToAttribValue|undefined} Cached sub-path for the JSX
|
|
1787
|
+
* whole-attribute fast path; see applyJsxAttr(). Declared so the first assignment
|
|
1788
|
+
* doesn't transition the hidden class. */
|
|
1789
|
+
jsxSub;
|
|
1790
|
+
|
|
1791
|
+
/** @type {?string} The attribute name jsxSub was built for. */
|
|
1792
|
+
jsxSubName;
|
|
1462
1793
|
|
|
1463
1794
|
constructor(nodeBefore, nodeMarker) {
|
|
1464
|
-
|
|
1465
|
-
|
|
1795
|
+
// nodeBefore is discarded: an attribute path has no nodes of its own. The marker goes
|
|
1796
|
+
// straight through the base constructor rather than being stored a second time after it.
|
|
1797
|
+
super(null, nodeMarker);
|
|
1466
1798
|
this.attrNames = new Set();
|
|
1467
1799
|
}
|
|
1468
1800
|
|
|
1469
|
-
/**
|
|
1470
|
-
* @param exprs {Expr[][]} Only the first is used. */
|
|
1471
|
-
apply(exprs) {
|
|
1472
|
-
//#IFDEV
|
|
1473
|
-
assert(Array.isArray(exprs));
|
|
1474
|
-
//#ENDIF
|
|
1475
|
-
this.applySingle(exprs[0]);
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
1801
|
/**
|
|
1479
1802
|
* @param expr {Expr} */
|
|
1480
1803
|
applySingle(expr) {
|
|
@@ -1553,17 +1876,13 @@ class PathToAttribs extends Path {
|
|
|
1553
1876
|
value = styleToCss(value);
|
|
1554
1877
|
sub.applySingle(value);
|
|
1555
1878
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
getExpressionCount() { return 1 }
|
|
1559
|
-
getValue(exprs) { return exprs[0]; }
|
|
1560
1879
|
}
|
|
1561
1880
|
|
|
1562
1881
|
/**
|
|
1563
1882
|
* Maps a string key to multiple values.
|
|
1564
1883
|
* Values are stored in arrays because pushing them is much faster than Set operations,
|
|
1565
1884
|
* and deleteAny() needs no iterator allocation.
|
|
1566
|
-
* deleteAny() returns values first-in-first-out by advancing a head index (array.
|
|
1885
|
+
* deleteAny() returns values first-in-first-out by advancing a head index (array.hd)
|
|
1567
1886
|
* instead of calling shift(), which would be O(n). */
|
|
1568
1887
|
class MultiValueMap {
|
|
1569
1888
|
|
|
@@ -1591,7 +1910,7 @@ class MultiValueMap {
|
|
|
1591
1910
|
let array = data[key];
|
|
1592
1911
|
if (!array)
|
|
1593
1912
|
data[key] = [value];
|
|
1594
|
-
else if (array.length - (array.
|
|
1913
|
+
else if (array.length - (array.hd || 0) < max)
|
|
1595
1914
|
array.push(value);
|
|
1596
1915
|
}
|
|
1597
1916
|
|
|
@@ -1605,20 +1924,75 @@ class MultiValueMap {
|
|
|
1605
1924
|
if (!array) // slower than pre-check.
|
|
1606
1925
|
return undefined;
|
|
1607
1926
|
|
|
1608
|
-
let head = array.
|
|
1927
|
+
let head = array.hd || 0;
|
|
1609
1928
|
let result = array[head];
|
|
1610
1929
|
head++;
|
|
1611
1930
|
if (head >= array.length)
|
|
1612
1931
|
delete data[key];
|
|
1613
1932
|
else
|
|
1614
|
-
array.
|
|
1933
|
+
array.hd = head;
|
|
1615
1934
|
|
|
1616
1935
|
return result;
|
|
1617
1936
|
}
|
|
1618
1937
|
}
|
|
1619
1938
|
|
|
1939
|
+
/**
|
|
1940
|
+
* A list of items plus the function that builds one item's Template, as returned by h.map().
|
|
1941
|
+
*
|
|
1942
|
+
* Handing the reconciler the source items instead of an array of Templates is what makes
|
|
1943
|
+
* h.map() cheap on a long list: a row whose item is the same object it was built from needs
|
|
1944
|
+
* neither a Template built for it nor a cache lookup to find one, just an identity check
|
|
1945
|
+
* against the item the row already remembers. Rows that moved are recognized too — see
|
|
1946
|
+
* PathToNodes.applyMapped(), which follows a shifted list's offset and, failing that, matches
|
|
1947
|
+
* items against the Templates the previous render built.
|
|
1948
|
+
*/
|
|
1949
|
+
class MappedList {
|
|
1950
|
+
|
|
1951
|
+
/** @type {Array} */
|
|
1952
|
+
items;
|
|
1953
|
+
|
|
1954
|
+
/** @type {function(*):Template} */
|
|
1955
|
+
fn;
|
|
1956
|
+
|
|
1957
|
+
constructor(items, fn) {
|
|
1958
|
+
this.items = items;
|
|
1959
|
+
this.fn = fn;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* Yield the Templates, building each one as it goes, so that code written against the older
|
|
1964
|
+
* array-returning h.map() — spreading it, iterating it, passing it to Array.from — still
|
|
1965
|
+
* works. Doing so builds every row, which is exactly the work the reconciler skips when the
|
|
1966
|
+
* list is handed to it whole, so prefer putting an h.map() straight into a template. */
|
|
1967
|
+
*[Symbol.iterator]() {
|
|
1968
|
+
let items = this.items, fn = this.fn;
|
|
1969
|
+
for (let i=0; i<items.length; i++)
|
|
1970
|
+
yield fn(items[i]);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1620
1974
|
class PathToNodes extends Path {
|
|
1621
1975
|
|
|
1976
|
+
/** @type {boolean} True once any NodeGroup this path created needs a visit even when its
|
|
1977
|
+
* values are unchanged (it holds a component or a live HTML property). Those rows are the
|
|
1978
|
+
* reason the list scans exist, so their presence rules out applyMisses()' skip-the-scan
|
|
1979
|
+
* path. Sticky: it's never cleared, which can only cost a scan that wasn't needed. */
|
|
1980
|
+
anyNeedsRefresh = false;
|
|
1981
|
+
|
|
1982
|
+
/** @type {?Array} The h.map() items the previous render drew, one per NodeGroup and in the
|
|
1983
|
+
* same order, so an unchanged row is recognized by comparing two arrays rather than by
|
|
1984
|
+
* following a pointer into each NodeGroup. A thousand rows' NodeGroups are scattered over
|
|
1985
|
+
* a hundred kilobytes, so reading a field from each one costs a cache miss apiece; two flat
|
|
1986
|
+
* arrays walk in step. Null whenever the last render wasn't an h.map().
|
|
1987
|
+
* @type {?Array} */
|
|
1988
|
+
lastItems = null;
|
|
1989
|
+
|
|
1990
|
+
/** @type {boolean} True when the previous render's items contained raw DOM Nodes,
|
|
1991
|
+
* which routes applySingle() to the generic reconciler. Declared so the hot
|
|
1992
|
+
* `!this.itemsHaveNodes` check reads a real field instead of a missing property,
|
|
1993
|
+
* and so the first raw-Node render doesn't transition the hidden class. */
|
|
1994
|
+
itemsHaveNodes = false;
|
|
1995
|
+
|
|
1622
1996
|
/** @type {?NodeGroup[]} The NodeGroups created by this path's expression, in order.
|
|
1623
1997
|
* Lazily created; null when the path has only ever rendered a primitive (see textNode). */
|
|
1624
1998
|
nodeGroups = null;
|
|
@@ -1632,14 +2006,6 @@ class PathToNodes extends Path {
|
|
|
1632
2006
|
|
|
1633
2007
|
|
|
1634
2008
|
|
|
1635
|
-
/**
|
|
1636
|
-
* Nodes that have been used during the current render().
|
|
1637
|
-
* Used with getNodeGroup() and freeNodeGroups() on the generic path; the positional diff
|
|
1638
|
-
* tracks in-use NodeGroups in this.nodeGroups instead.
|
|
1639
|
-
* Lazily created since most paths never use it.
|
|
1640
|
-
* @type {?NodeGroup[]} */
|
|
1641
|
-
nodeGroupsRendered = null;
|
|
1642
|
-
|
|
1643
2009
|
/**
|
|
1644
2010
|
* Nodes that were added to the web component during the last render(), but are available to be used again.
|
|
1645
2011
|
* Used with getNodeGroup() and freeNodeGroups(), keyed by close key.
|
|
@@ -1657,18 +2023,6 @@ class PathToNodes extends Path {
|
|
|
1657
2023
|
super(nodeBefore, nodeMarker);
|
|
1658
2024
|
}
|
|
1659
2025
|
|
|
1660
|
-
/**
|
|
1661
|
-
* Insert/replace the nodes created by a single expression.
|
|
1662
|
-
* Called by applyExprs()
|
|
1663
|
-
* @param exprs {Expr[]} Only the first is used.
|
|
1664
|
-
* @return {Node[]} New Nodes created. */
|
|
1665
|
-
apply(exprs) {
|
|
1666
|
-
//#IFDEV
|
|
1667
|
-
assert(Array.isArray(exprs));
|
|
1668
|
-
//#ENDIF
|
|
1669
|
-
this.applySingle(exprs[0]);
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
2026
|
/**
|
|
1673
2027
|
* Make the DOM between nodeBefore and nodeMarker match the value of expr.
|
|
1674
2028
|
* This is the main entry point for rendering an expression's nodes, chosen from three strategies:
|
|
@@ -1680,7 +2034,7 @@ class PathToNodes extends Path {
|
|
|
1680
2034
|
* @param expr {Expr} */
|
|
1681
2035
|
applySingle(expr) {
|
|
1682
2036
|
|
|
1683
|
-
/*#
|
|
2037
|
+
/*#IFDEBUG*/this.verify();/*#ENDIF*/
|
|
1684
2038
|
|
|
1685
2039
|
// Fast path for a single primitive expression, the most common case in loops.
|
|
1686
2040
|
let exprType = typeof expr;
|
|
@@ -1756,31 +2110,452 @@ class PathToNodes extends Path {
|
|
|
1756
2110
|
this.textNode = null;
|
|
1757
2111
|
}
|
|
1758
2112
|
|
|
1759
|
-
//
|
|
2113
|
+
// A selection binding only knows how to write an attribute, so catch it here rather than
|
|
2114
|
+
// letting it render as an empty string and leave the caller wondering where it went.
|
|
2115
|
+
if (expr instanceof SelectorRef)
|
|
2116
|
+
throw new Error('Solarite: a selector must own the whole attribute.');
|
|
2117
|
+
|
|
2118
|
+
// 1. h.map() hands over its source items and callback rather than built Templates, so a
|
|
2119
|
+
// row whose item is unchanged is recognized without building or looking up a Template.
|
|
2120
|
+
if (expr instanceof MappedList) {
|
|
2121
|
+
this.applyMapped(expr);
|
|
2122
|
+
/*#IFDEBUG*/this.verify();/*#ENDIF*/
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// Anything that isn't an h.map() leaves no items to recognize rows by next time.
|
|
2127
|
+
this.lastItems = null;
|
|
2128
|
+
|
|
2129
|
+
// 2. Flatten the expression to a list of Templates, strings and Nodes, evaluating functions along the way.
|
|
2130
|
+
// A flat array that is entirely Templates — the rows.map(...) shape that list renders
|
|
2131
|
+
// produce — is borrowed directly instead of copied. The borrow lasts only for the
|
|
2132
|
+
// rest of this synchronous call: applyDiff/applyKeyed/applyGeneric read the items and
|
|
2133
|
+
// retain only the NodeGroups (and each item's own Template) built from them, never the
|
|
2134
|
+
// items array itself, so no reference to the caller's array survives the render. Keep
|
|
2135
|
+
// that invariant — storing newItems on any long-lived object would pin the caller's
|
|
2136
|
+
// per-render array until the next render, moving its collection into a later frame.
|
|
1760
2137
|
/** @type {(Template|string|Node)[]} */
|
|
1761
|
-
let newItems =
|
|
1762
|
-
let hasNodesNow =
|
|
2138
|
+
let newItems = null;
|
|
2139
|
+
let hasNodesNow = false;
|
|
2140
|
+
if (Array.isArray(expr)) {
|
|
2141
|
+
let len = expr.length, i = 0;
|
|
2142
|
+
while (i < len && expr[i] instanceof Template)
|
|
2143
|
+
i++;
|
|
2144
|
+
if (i === len)
|
|
2145
|
+
newItems = expr; // Borrowed from the caller; read-only from here on.
|
|
2146
|
+
}
|
|
2147
|
+
if (newItems === null) {
|
|
2148
|
+
newItems = [];
|
|
2149
|
+
hasNodesNow = this.collectItems(expr, newItems, false);
|
|
2150
|
+
}
|
|
1763
2151
|
|
|
1764
|
-
//
|
|
2152
|
+
// 3. Raw Nodes in the items (now or on the previous render) can't be diffed positionally
|
|
1765
2153
|
// because this.nodeGroups only tracks NodeGroups. Use the generic path for those.
|
|
1766
2154
|
if (hasNodesNow || this.itemsHaveNodes) {
|
|
1767
2155
|
this.itemsHaveNodes = hasNodesNow;
|
|
1768
2156
|
this.applyGeneric(newItems);
|
|
1769
2157
|
}
|
|
1770
|
-
else
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
2158
|
+
else
|
|
2159
|
+
this.diffItems(newItems);
|
|
2160
|
+
|
|
2161
|
+
/*#IFDEBUG*/this.verify();/*#ENDIF*/
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* Reconcile a flat list of Templates and strings against this path's NodeGroups.
|
|
2166
|
+
* Templates with a key=${} attribute diff by key so node identity follows the data.
|
|
2167
|
+
* An empty list also routes to applyKeyed when the previous render was keyed, so removed
|
|
2168
|
+
* keyed NodeGroups are discarded instead of pooled.
|
|
2169
|
+
* @param newItems {(Template|string)[]} */
|
|
2170
|
+
diffItems(newItems) {
|
|
2171
|
+
let first = newItems.length !== 0 ? newItems[0] : null;
|
|
2172
|
+
if (first !== null
|
|
2173
|
+
? (typeof first !== 'string' && (first.key !== undefined || Shell.get(first.html, first.svgMode).keyIndex >= 0))
|
|
2174
|
+
: (this.nodeGroups !== null && this.nodeGroups.length !== 0 && this.nodeGroups[0].key !== undefined))
|
|
2175
|
+
this.applyKeyed(newItems);
|
|
2176
|
+
else
|
|
2177
|
+
this.applyDiff(newItems);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/**
|
|
2181
|
+
* Render an h.map() list.
|
|
2182
|
+
*
|
|
2183
|
+
* What makes this cheaper than reconciling an array of Templates is that a row still holding
|
|
2184
|
+
* the item it was built from needs no Template at all: it is recognized by one identity
|
|
2185
|
+
* check, with nothing built and nothing compared. When the list is the same length and only
|
|
2186
|
+
* a few rows changed, that is the whole render — see applyMisses(). Otherwise the walk
|
|
2187
|
+
* follows the offset a shifted list settles on, and finally consults a map from item to the
|
|
2188
|
+
* Template the previous render built, so rows that moved far are still reused.
|
|
2189
|
+
* @param mapped {MappedList} */
|
|
2190
|
+
applyMapped(mapped) {
|
|
2191
|
+
let items = mapped.items, fn = mapped.fn;
|
|
2192
|
+
let len = items.length;
|
|
2193
|
+
let oldNgs = this.nodeGroups;
|
|
2194
|
+
// Only rows this path drew from an h.map() last time can be recognized by their item;
|
|
2195
|
+
// anything else starts over.
|
|
2196
|
+
let lastItems = this.lastItems;
|
|
2197
|
+
let oldLen = oldNgs === null || lastItems === null || lastItems.length !== oldNgs.length
|
|
2198
|
+
? 0 : oldNgs.length;
|
|
2199
|
+
|
|
2200
|
+
// Patch path. When the list is the same length as last time, every row that still holds
|
|
2201
|
+
// the item it was built from is already final: it needs no Template, no comparison and no
|
|
2202
|
+
// visit. So find the positions that did change, build only those, and patch them. That
|
|
2203
|
+
// makes a selection or a partial update cost work proportional to the change instead of
|
|
2204
|
+
// to the length of the list. Rows that must be visited even when unchanged (components,
|
|
2205
|
+
// live HTML properties) rule it out, since revisiting them is what the full scan is for.
|
|
2206
|
+
let misses = null, missTemplates = null, missCount = 0;
|
|
2207
|
+
if (oldLen === len && len !== 0 && !this.anyNeedsRefresh && !this.itemsHaveNodes) {
|
|
2208
|
+
let tooMany = false;
|
|
2209
|
+
let cap = missProbeThreshold;
|
|
2210
|
+
|
|
2211
|
+
// First find WHICH positions changed, without building anything for them. A change
|
|
2212
|
+
// this path can't handle is then abandoned having cost only comparisons — building
|
|
2213
|
+
// as we went would throw away a Template for every row of, say, a reversed list,
|
|
2214
|
+
// which the general diff is about to reuse from the previous render.
|
|
2215
|
+
for (let i=0; i<len; i++) {
|
|
2216
|
+
if (lastItems[i] !== items[i]) {
|
|
2217
|
+
if (missCount === cap) {
|
|
2218
|
+
// Enough of the list has changed to ask what kind of change this is,
|
|
2219
|
+
// because the two kinds want opposite treatment. If the item at this
|
|
2220
|
+
// position is somewhere else in the old list, the rows were reordered,
|
|
2221
|
+
// and the general diff's item map will reuse their Templates instead of
|
|
2222
|
+
// rebuilding them — so stop here and let it. If the item is new, the
|
|
2223
|
+
// rows' contents changed, and there is nothing to reuse: keep going and
|
|
2224
|
+
// patch them all, however many there are. The scan costs one pass over
|
|
2225
|
+
// the old rows, once, and only for a list that changed this much.
|
|
2226
|
+
if (itemIsElsewhere(lastItems, oldLen, items[i])) {
|
|
2227
|
+
tooMany = true;
|
|
2228
|
+
missCount = 0; // Nothing was built, so the general path has nothing to reuse.
|
|
2229
|
+
break;
|
|
2230
|
+
}
|
|
2231
|
+
cap = len; // Asked and answered; there is no second probe.
|
|
2232
|
+
}
|
|
2233
|
+
(misses ??= [])[missCount++] = i;
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// Now build them.
|
|
2238
|
+
if (!tooMany && missCount !== 0) {
|
|
2239
|
+
missTemplates = new Array(missCount);
|
|
2240
|
+
for (let k=0; k<missCount; k++) {
|
|
2241
|
+
let t = fn(items[misses[k]]);
|
|
2242
|
+
if (!(t instanceof Template) && typeof t !== 'string') { // A Node, an array, …
|
|
2243
|
+
tooMany = true;
|
|
2244
|
+
missCount = k; // Keep the ones already built; the rest are the caller's problem.
|
|
2245
|
+
break;
|
|
2246
|
+
}
|
|
2247
|
+
missTemplates[k] = t;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
if (!tooMany && (missCount === 0
|
|
2251
|
+
|| this.applyMisses(oldNgs, misses, missTemplates, missCount, len))) {
|
|
2252
|
+
for (let k=0; k<missCount; k++) {
|
|
2253
|
+
let j = misses[k];
|
|
2254
|
+
lastItems[j] = items[j];
|
|
2255
|
+
}
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// General path: build the whole list of Templates and hand it to the reconciler.
|
|
2261
|
+
let newItems = new Array(len);
|
|
2262
|
+
let built = missCount !== 0 ? misses : null, b = 0;
|
|
2263
|
+
let itemMap = null, noItemMap = false;
|
|
2264
|
+
const indexOfItem = item => {
|
|
2265
|
+
if (noItemMap)
|
|
2266
|
+
return -1;
|
|
2267
|
+
if (itemMap === null) {
|
|
2268
|
+
// One scan before paying for a map: if this item is nowhere in the old rows, the
|
|
2269
|
+
// list's contents changed rather than moved, so there is nothing to look up and
|
|
2270
|
+
// every later miss can go straight to the callback. A scan is cheaper than a map
|
|
2271
|
+
// of every row, and this is the common shape — rows replaced in place.
|
|
2272
|
+
if (!itemIsElsewhere(lastItems, oldLen, item)) {
|
|
2273
|
+
noItemMap = true;
|
|
2274
|
+
return -1;
|
|
2275
|
+
}
|
|
2276
|
+
itemMap = new Map();
|
|
2277
|
+
for (let k=0; k<oldLen; k++)
|
|
2278
|
+
itemMap.set(lastItems[k], k);
|
|
2279
|
+
}
|
|
2280
|
+
let k = itemMap.get(item);
|
|
2281
|
+
return k === undefined ? -1 : k;
|
|
2282
|
+
};
|
|
2283
|
+
// Walk the two lists together. A row is recognized by the item it was built from, at the
|
|
2284
|
+
// offset the walk has settled on: after an insertion or a removal every later row sits a
|
|
2285
|
+
// fixed distance from where it was, and following that keeps recognizing them instead of
|
|
2286
|
+
// treating the whole tail as changed. The short search that re-establishes the offset
|
|
2287
|
+
// only runs while the walk is still in step, so a list of genuinely new rows (an append,
|
|
2288
|
+
// a replace-all) gives up after one miss rather than searching for every row. Failing
|
|
2289
|
+
// all that, a map from item to the Template the previous render built for it catches
|
|
2290
|
+
// rows that moved far — a sort, a shuffle. It's built on demand, from the rows this
|
|
2291
|
+
// path already holds: a persistent per-item cache would instead pay a write for every
|
|
2292
|
+
// row of every list ever created, which is most of the work of building a list from
|
|
2293
|
+
// scratch, and would hold each Template alive for as long as the caller holds the item.
|
|
2294
|
+
if (oldLen !== 0) {
|
|
2295
|
+
let delta = 0, inSync = true;
|
|
2296
|
+
for (let i=0; i<len; i++) {
|
|
2297
|
+
let item = items[i];
|
|
2298
|
+
let j = i + delta;
|
|
2299
|
+
let inRange = j >= 0 && j < oldLen;
|
|
2300
|
+
if (inRange && lastItems[j] === item) {
|
|
2301
|
+
newItems[i] = oldNgs[j].template;
|
|
2302
|
+
inSync = true;
|
|
2303
|
+
continue;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// This position was already found to have changed, and its Template built, by the
|
|
2307
|
+
// patch scan above. That only happens for a same-length list, where the offset
|
|
2308
|
+
// stays zero, so there's no search to redo here.
|
|
2309
|
+
if (built !== null && b < missCount && built[b] === i) {
|
|
2310
|
+
newItems[i] = missTemplates[b++];
|
|
2311
|
+
continue;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
if (inSync) {
|
|
2315
|
+
let found = -1;
|
|
2316
|
+
for (let d=1; d<=shiftSearchDistance; d++) {
|
|
2317
|
+
let after = j + d, before = j - d;
|
|
2318
|
+
if (after < oldLen && lastItems[after] === item) {
|
|
2319
|
+
found = after;
|
|
2320
|
+
break;
|
|
2321
|
+
}
|
|
2322
|
+
if (before >= 0 && lastItems[before] === item) {
|
|
2323
|
+
found = before;
|
|
2324
|
+
break;
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
if (found >= 0) {
|
|
2328
|
+
delta = found - i;
|
|
2329
|
+
newItems[i] = oldNgs[found].template;
|
|
2330
|
+
continue;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// The item isn't in the old list at all, but the old row standing here
|
|
2334
|
+
// belongs to an item a little further along: rows were INSERTED here. Build
|
|
2335
|
+
// this one and shift the offset, so the rest of the list is still recognized.
|
|
2336
|
+
// Without this, prepending one row to a long list would look like a change to
|
|
2337
|
+
// every row in it. Only worth asking when the list actually grew.
|
|
2338
|
+
if (inRange && len > oldLen)
|
|
2339
|
+
for (let d=1; d<=insertSearchDistance && i+d<len; d++)
|
|
2340
|
+
if (items[i+d] === lastItems[j]) {
|
|
2341
|
+
newItems[i] = fn(item);
|
|
2342
|
+
delta--;
|
|
2343
|
+
found = -2; // Handled; skip the fallbacks below.
|
|
2344
|
+
break;
|
|
2345
|
+
}
|
|
2346
|
+
if (found === -2)
|
|
2347
|
+
continue;
|
|
2348
|
+
|
|
2349
|
+
inSync = false;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Past the end of the old list there is nothing left to match, so appended rows
|
|
2353
|
+
// go straight to the callback instead of paying for a lookup that must miss.
|
|
2354
|
+
if (j < oldLen) {
|
|
2355
|
+
let k = indexOfItem(item);
|
|
2356
|
+
if (k >= 0) {
|
|
2357
|
+
newItems[i] = oldNgs[k].template;
|
|
2358
|
+
delta = k - i; // Back in step; the rest of the list can walk positionally again.
|
|
2359
|
+
inSync = true;
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
newItems[i] = fn(item);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
else
|
|
2368
|
+
for (let i=0; i<len; i++)
|
|
2369
|
+
newItems[i] = fn(items[i]);
|
|
2370
|
+
|
|
2371
|
+
// A callback that returns something other than a Template or a string (a raw Node, an
|
|
2372
|
+
// array, a nested list) can't be diffed positionally; flatten it the general way.
|
|
2373
|
+
let first = len !== 0 ? newItems[0] : null;
|
|
2374
|
+
if (first !== null && !(first instanceof Template) && typeof first !== 'string') {
|
|
2375
|
+
let flat = [];
|
|
2376
|
+
let hasNodesNow = this.collectItems(newItems, flat, false);
|
|
2377
|
+
if (hasNodesNow || this.itemsHaveNodes) {
|
|
2378
|
+
this.itemsHaveNodes = hasNodesNow;
|
|
2379
|
+
this.applyGeneric(flat);
|
|
2380
|
+
}
|
|
2381
|
+
else
|
|
2382
|
+
this.diffItems(flat);
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
if (this.itemsHaveNodes) {
|
|
2387
|
+
this.itemsHaveNodes = false;
|
|
2388
|
+
this.applyGeneric(newItems);
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
this.diffItems(newItems);
|
|
2393
|
+
|
|
2394
|
+
// Remember which item drew each row, so the next render can match them by identity.
|
|
2395
|
+
// The reconciler leaves nodeGroups aligned with newItems, and therefore with items.
|
|
2396
|
+
// The caller's array is copied rather than kept, since the caller mutates it in place.
|
|
2397
|
+
let li = this.lastItems;
|
|
2398
|
+
if (li === null || li.length !== len)
|
|
2399
|
+
li = this.lastItems = new Array(len);
|
|
2400
|
+
for (let j=0; j<len; j++)
|
|
2401
|
+
li[j] = items[j];
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
/**
|
|
2405
|
+
* Patch only the positions an h.map() render changed, leaving every other row alone.
|
|
2406
|
+
*
|
|
2407
|
+
* Every unchanged position already holds the NodeGroup built from that exact item, so it
|
|
2408
|
+
* needs no visit at all; only the changed positions can require a rewrite, a move, or a new
|
|
2409
|
+
* row. Changed positions are handled in two steps, the same shape as the general keyed
|
|
2410
|
+
* diff's small-reorder path: first the ones that kept their key (a row whose data changed
|
|
2411
|
+
* in place), then the leftovers are cross-matched against each other by key so a swap or a
|
|
2412
|
+
* short shuffle moves the fewest node ranges.
|
|
2413
|
+
*
|
|
2414
|
+
* @param ngs {NodeGroup[]} This path's NodeGroups, patched in place.
|
|
2415
|
+
* @param misses {int[]} Positions whose item changed, ascending.
|
|
2416
|
+
* @param templates {(Template|string)[]} The new Template for each of those positions.
|
|
2417
|
+
* @param missCount {int}
|
|
2418
|
+
* @param len {int} Length of the list, for anchoring the last position.
|
|
2419
|
+
* @return {boolean} False when the change doesn't fit this path and the caller must run
|
|
2420
|
+
* the general diff instead; nothing has been modified in that case. */
|
|
2421
|
+
applyMisses(ngs, misses, templates, missCount, len) {
|
|
2422
|
+
|
|
2423
|
+
// Only a keyed list can move rows around safely. An unkeyed one can still be rewritten
|
|
2424
|
+
// in place, which is what the positional diff would do for it anyway.
|
|
2425
|
+
let keyed = ngs[0].key !== undefined;
|
|
2426
|
+
|
|
2427
|
+
// 1. Classify the changed positions without touching anything, so that a change too big
|
|
2428
|
+
// for this path can still be handed to the general diff with nothing half-applied.
|
|
2429
|
+
// A row that kept its key is rewritten where it stands; the rest have to be matched
|
|
2430
|
+
// against each other, and past a handful of those the general diff's map-and-LIS
|
|
2431
|
+
// approach is the better tool.
|
|
2432
|
+
let displaced = null, dCount = 0;
|
|
2433
|
+
for (let k=0; k<missCount; k++) {
|
|
2434
|
+
let ng = ngs[misses[k]], t = templates[k];
|
|
2435
|
+
if (typeof t === 'string' || !itemClose(ng, t) || (keyed && ng.key !== keyOf(t))) {
|
|
2436
|
+
if (!keyed || dCount === maxDisplacedMisses)
|
|
2437
|
+
return false;
|
|
2438
|
+
(displaced ??= [])[dCount++] = k;
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// 2. Rewrite the rows that kept their key. displaced holds indexes into misses in
|
|
2443
|
+
// ascending order, so one pointer walks past them.
|
|
2444
|
+
for (let k=0, d=0; k<missCount; k++) {
|
|
2445
|
+
if (d < dCount && displaced[d] === k) {
|
|
2446
|
+
d++;
|
|
2447
|
+
continue;
|
|
2448
|
+
}
|
|
2449
|
+
let ng = ngs[misses[k]], t = templates[k];
|
|
2450
|
+
if (itemSame(ng, t))
|
|
2451
|
+
this.refreshSameItem(ng, t);
|
|
1779
2452
|
else
|
|
1780
|
-
this.
|
|
2453
|
+
this.rewriteNodeGroup(ng, t);
|
|
2454
|
+
}
|
|
2455
|
+
if (dCount === 0)
|
|
2456
|
+
return true;
|
|
2457
|
+
|
|
2458
|
+
// 3. Hand the displaced rows to the shared placer. displaced holds indexes into misses
|
|
2459
|
+
// and templates, so misses is what maps a row to its position in the list.
|
|
2460
|
+
let wholeParent = this.wholeParent;
|
|
2461
|
+
this.placeDisplaced(displaced, misses, ngs, templates, ngs, len,
|
|
2462
|
+
wholeParent ? null : this.nodeMarker,
|
|
2463
|
+
wholeParent ? this.nodeMarker : this.nodeMarker.parentNode);
|
|
2464
|
+
|
|
2465
|
+
// 4. Node membership or order changed, so invalidate caches.
|
|
2466
|
+
if (!this.parentNg.firstApply) {
|
|
2467
|
+
this.nodesCache = null;
|
|
2468
|
+
if (this.parentNg.parentPath)
|
|
2469
|
+
this.parentNg.parentPath.clearNodesCache();
|
|
1781
2470
|
}
|
|
1782
2471
|
|
|
1783
|
-
|
|
2472
|
+
// Keep state used by the generic path from going stale.
|
|
2473
|
+
if (this.nodeGroupsAttachedAvailable)
|
|
2474
|
+
this.nodeGroupsAttachedAvailable = null;
|
|
2475
|
+
return true;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/**
|
|
2479
|
+
* Settle a handful of rows that moved, appeared or vanished within one window of a list.
|
|
2480
|
+
*
|
|
2481
|
+
* Both small-reorder paths — the h.map() patch in applyMisses and the equal-length window in
|
|
2482
|
+
* applyKeyed — reach the same point: a few positions whose old NodeGroup no longer belongs
|
|
2483
|
+
* where it stands, everything around them already correct. Since every candidate came from
|
|
2484
|
+
* this same window, a swap, a dragged row or a short shuffle finds its partners inside it, so
|
|
2485
|
+
* the rows are cross-matched against each other by key rather than through the general
|
|
2486
|
+
* diff's key map and longest-increasing-subsequence machinery.
|
|
2487
|
+
*
|
|
2488
|
+
* rows holds ascending indexes into items, which is the array each caller already has; when
|
|
2489
|
+
* those indexes are not themselves list positions, positions maps them across. Doing the
|
|
2490
|
+
* indirection here rather than compacting it away in the caller keeps this off the allocation
|
|
2491
|
+
* path: neither caller builds an array it wasn't building already. rows.length is small by
|
|
2492
|
+
* construction (at most maxDisplacedMisses), which is what makes the O(n²) cross-match
|
|
2493
|
+
* cheaper than building a map.
|
|
2494
|
+
*
|
|
2495
|
+
* @param rows {int[]} Ascending indexes of the rows to settle.
|
|
2496
|
+
* @param positions {int[]|null} Maps a row index to its list position, or null when the row
|
|
2497
|
+
* indexes are already positions.
|
|
2498
|
+
* @param oldNgs {NodeGroup[]} Where each position's outgoing NodeGroup is read from.
|
|
2499
|
+
* @param items {(Template|string)[]} The new items, indexed by row index.
|
|
2500
|
+
* @param outNgs {NodeGroup[]} Receives the NodeGroup that ends up at each position. May be
|
|
2501
|
+
* the same array as oldNgs; the outgoing groups are snapshotted before anything is written.
|
|
2502
|
+
* @param boundary {int} First position past this window, where the anchor stops being
|
|
2503
|
+
* outNgs[p+1] and becomes tailAnchor.
|
|
2504
|
+
* @param tailAnchor {Node|null} Anchor for a row placed at boundary-1.
|
|
2505
|
+
* @param parent {Node} Where the rows' nodes live. */
|
|
2506
|
+
placeDisplaced(rows, positions, oldNgs, items, outNgs, boundary, tailAnchor, parent) {
|
|
2507
|
+
let count = rows.length;
|
|
2508
|
+
|
|
2509
|
+
// 1. Cross-match the rows against each other by key. A claimed NodeGroup is nulled out
|
|
2510
|
+
// of the snapshot so it can't be claimed twice.
|
|
2511
|
+
let free = new Array(count);
|
|
2512
|
+
for (let b=0; b<count; b++) {
|
|
2513
|
+
let i = rows[b];
|
|
2514
|
+
free[b] = oldNgs[positions === null ? i : positions[i]];
|
|
2515
|
+
}
|
|
2516
|
+
let placed = new Array(count);
|
|
2517
|
+
for (let a=0; a<count; a++) {
|
|
2518
|
+
let t = items[rows[a]];
|
|
2519
|
+
let key = keyOf(t);
|
|
2520
|
+
if (key !== undefined)
|
|
2521
|
+
for (let b=0; b<count; b++) {
|
|
2522
|
+
let ng = free[b];
|
|
2523
|
+
if (ng !== null && ng.key === key && itemClose(ng, t)) {
|
|
2524
|
+
free[b] = null;
|
|
2525
|
+
if (itemSame(ng, t))
|
|
2526
|
+
this.refreshSameItem(ng, t);
|
|
2527
|
+
else
|
|
2528
|
+
this.rewriteNodeGroup(ng, t);
|
|
2529
|
+
placed[a] = ng;
|
|
2530
|
+
break;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// 2. Discard the old rows nothing claimed. Keyed semantics require a new key to get new
|
|
2536
|
+
// nodes, so these are never pooled.
|
|
2537
|
+
for (let b=0; b<count; b++) {
|
|
2538
|
+
let ng = free[b];
|
|
2539
|
+
if (ng !== null) {
|
|
2540
|
+
if (ng.startNode !== ng.endNode)
|
|
2541
|
+
Util.saveOrphans(ng.getNodes());
|
|
2542
|
+
else
|
|
2543
|
+
ng.startNode.remove();
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// 3. Put the rows in place, right to left so each one's anchor is already final.
|
|
2548
|
+
for (let a=count-1; a>=0; a--) {
|
|
2549
|
+
let i = rows[a];
|
|
2550
|
+
let p = positions === null ? i : positions[i];
|
|
2551
|
+
let ng = placed[a];
|
|
2552
|
+
if (ng === undefined)
|
|
2553
|
+
ng = this.createNew(items[i]);
|
|
2554
|
+
outNgs[p] = ng;
|
|
2555
|
+
let anchor = p+1 < boundary ? outNgs[p+1].startNode : tailAnchor;
|
|
2556
|
+
if (ng.endNode.nextSibling !== anchor || ng.startNode.parentNode !== parent)
|
|
2557
|
+
insertNodesBefore(parent, ng, anchor);
|
|
2558
|
+
}
|
|
1784
2559
|
}
|
|
1785
2560
|
|
|
1786
2561
|
/**
|
|
@@ -1803,8 +2578,8 @@ class PathToNodes extends Path {
|
|
|
1803
2578
|
let ng = oldNgs[start], t = newItems[start];
|
|
1804
2579
|
if (!itemSame(ng, t))
|
|
1805
2580
|
break;
|
|
1806
|
-
if (ng.
|
|
1807
|
-
|
|
2581
|
+
if (ng.shell.needsRefresh)
|
|
2582
|
+
this.refreshSameItem(ng, t);
|
|
1808
2583
|
newNgs[start] = ng;
|
|
1809
2584
|
start++;
|
|
1810
2585
|
}
|
|
@@ -1814,8 +2589,8 @@ class PathToNodes extends Path {
|
|
|
1814
2589
|
let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
|
|
1815
2590
|
if (!itemSame(ng, t))
|
|
1816
2591
|
break;
|
|
1817
|
-
if (ng.
|
|
1818
|
-
|
|
2592
|
+
if (ng.shell.needsRefresh)
|
|
2593
|
+
this.refreshSameItem(ng, t);
|
|
1819
2594
|
newNgs[--newEnd] = ng;
|
|
1820
2595
|
oldEnd--;
|
|
1821
2596
|
}
|
|
@@ -1824,8 +2599,8 @@ class PathToNodes extends Path {
|
|
|
1824
2599
|
while (start < oldEnd && start < newEnd) {
|
|
1825
2600
|
let ng = oldNgs[start], t = newItems[start];
|
|
1826
2601
|
if (itemSame(ng, t)) { // Can happen between changed rows, e.g. partial updates.
|
|
1827
|
-
if (ng.
|
|
1828
|
-
|
|
2602
|
+
if (ng.shell.needsRefresh)
|
|
2603
|
+
this.refreshSameItem(ng, t);
|
|
1829
2604
|
}
|
|
1830
2605
|
else if (itemClose(ng, t))
|
|
1831
2606
|
this.rewriteNodeGroup(ng, t);
|
|
@@ -1862,34 +2637,18 @@ class PathToNodes extends Path {
|
|
|
1862
2637
|
}
|
|
1863
2638
|
}
|
|
1864
2639
|
|
|
1865
|
-
// 5. Insert leftover new items.
|
|
2640
|
+
// 5. Insert leftover new items directly. Each row is one native insert; a
|
|
2641
|
+
// batching DocumentFragment would double the insert count for no benefit,
|
|
2642
|
+
// since style/layout work is deferred until the next frame either way.
|
|
1866
2643
|
if (newRemain) {
|
|
1867
2644
|
let wholeParent = this.wholeParent;
|
|
1868
2645
|
let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
|
|
1869
2646
|
let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
|
|
1870
|
-
let target = parent, before = anchor;
|
|
1871
|
-
let fragment = null;
|
|
1872
|
-
if (newRemain > 1) { // Batch-insert through a fragment.
|
|
1873
|
-
fragment = Globals$1.doc.createDocumentFragment();
|
|
1874
|
-
target = fragment;
|
|
1875
|
-
before = null;
|
|
1876
|
-
}
|
|
1877
2647
|
for (let i=start; i<newEnd; i++) {
|
|
1878
2648
|
let ng = this.createOrReuse(newItems[i]);
|
|
1879
2649
|
newNgs[i] = ng;
|
|
1880
|
-
|
|
1881
|
-
if (node === end) // Single-node NodeGroups are the common case in loops.
|
|
1882
|
-
target.insertBefore(node, before);
|
|
1883
|
-
else while (true) {
|
|
1884
|
-
let next = node.nextSibling;
|
|
1885
|
-
target.insertBefore(node, before);
|
|
1886
|
-
if (node === end)
|
|
1887
|
-
break;
|
|
1888
|
-
node = next;
|
|
1889
|
-
}
|
|
2650
|
+
insertNodesBefore(parent, ng, anchor);
|
|
1890
2651
|
}
|
|
1891
|
-
if (fragment)
|
|
1892
|
-
parent.insertBefore(fragment, anchor);
|
|
1893
2652
|
}
|
|
1894
2653
|
|
|
1895
2654
|
// 6. Node membership changed, so invalidate caches.
|
|
@@ -1904,8 +2663,6 @@ class PathToNodes extends Path {
|
|
|
1904
2663
|
this.nodeGroups = newNgs;
|
|
1905
2664
|
|
|
1906
2665
|
// Keep state used by the generic path from going stale.
|
|
1907
|
-
if (this.nodeGroupsRendered)
|
|
1908
|
-
this.nodeGroupsRendered = null;
|
|
1909
2666
|
if (this.nodeGroupsAttachedAvailable)
|
|
1910
2667
|
this.nodeGroupsAttachedAvailable = null;
|
|
1911
2668
|
}
|
|
@@ -1924,23 +2681,11 @@ class PathToNodes extends Path {
|
|
|
1924
2681
|
let oldLen = oldNgs.length, newLen = newItems.length;
|
|
1925
2682
|
let newNgs = new Array(newLen);
|
|
1926
2683
|
|
|
1927
|
-
|
|
1928
|
-
let keyHtml = null, keyIndex = -1;
|
|
1929
|
-
const keyOf = t => {
|
|
1930
|
-
if (t.key !== undefined) // JSX templates carry the key directly.
|
|
1931
|
-
return t.key;
|
|
1932
|
-
if (t.html !== keyHtml) {
|
|
1933
|
-
keyHtml = t.html;
|
|
1934
|
-
keyIndex = Shell.get(t.html, t.svgMode).keyIndex;
|
|
1935
|
-
}
|
|
1936
|
-
return keyIndex >= 0 ? t.exprs[keyIndex] : undefined;
|
|
1937
|
-
};
|
|
1938
|
-
|
|
1939
|
-
//#IFDEV
|
|
2684
|
+
//#IFDEBUG
|
|
1940
2685
|
{
|
|
1941
2686
|
let seen = new Set();
|
|
1942
2687
|
for (let t of newItems) {
|
|
1943
|
-
let k =
|
|
2688
|
+
let k = keyOf(t);
|
|
1944
2689
|
if (k === undefined)
|
|
1945
2690
|
console.warn('Unkeyed item in a keyed list; it will be rebuilt on every render:', t);
|
|
1946
2691
|
else if (seen.has(k))
|
|
@@ -1958,15 +2703,13 @@ class PathToNodes extends Path {
|
|
|
1958
2703
|
let ng = oldNgs[start], t = newItems[start];
|
|
1959
2704
|
// An identical Template instance (h.map) implies an identical key, so skip key extraction.
|
|
1960
2705
|
if (ng.template === t) {
|
|
1961
|
-
if (ng.
|
|
1962
|
-
|
|
2706
|
+
if (ng.shell.needsRefresh)
|
|
2707
|
+
this.refreshSameItem(ng, t);
|
|
1963
2708
|
}
|
|
1964
2709
|
else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
|
|
1965
2710
|
break;
|
|
1966
|
-
else if (itemSame(ng, t))
|
|
1967
|
-
|
|
1968
|
-
ng.applyExprs(t.exprs, false);
|
|
1969
|
-
}
|
|
2711
|
+
else if (itemSame(ng, t))
|
|
2712
|
+
this.refreshSameItem(ng, t);
|
|
1970
2713
|
else
|
|
1971
2714
|
this.rewriteNodeGroup(ng, t);
|
|
1972
2715
|
newNgs[start] = ng;
|
|
@@ -1977,15 +2720,13 @@ class PathToNodes extends Path {
|
|
|
1977
2720
|
while (oldEnd > start && newEnd > start) {
|
|
1978
2721
|
let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
|
|
1979
2722
|
if (ng.template === t) {
|
|
1980
|
-
if (ng.
|
|
1981
|
-
|
|
2723
|
+
if (ng.shell.needsRefresh)
|
|
2724
|
+
this.refreshSameItem(ng, t);
|
|
1982
2725
|
}
|
|
1983
2726
|
else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
|
|
1984
2727
|
break;
|
|
1985
|
-
else if (itemSame(ng, t))
|
|
1986
|
-
|
|
1987
|
-
ng.applyExprs(t.exprs, false);
|
|
1988
|
-
}
|
|
2728
|
+
else if (itemSame(ng, t))
|
|
2729
|
+
this.refreshSameItem(ng, t);
|
|
1989
2730
|
else
|
|
1990
2731
|
this.rewriteNodeGroup(ng, t);
|
|
1991
2732
|
newNgs[--newEnd] = ng;
|
|
@@ -1997,6 +2738,57 @@ class PathToNodes extends Path {
|
|
|
1997
2738
|
let wholeParent = this.wholeParent;
|
|
1998
2739
|
let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
|
|
1999
2740
|
|
|
2741
|
+
// 3a. Equal-length windows: scan them aligned. Rows whose keys match positionally
|
|
2742
|
+
// are updated in place with no bookkeeping, and when at most 8 positions are
|
|
2743
|
+
// displaced (a swap, a dragged row, a small shuffle) they're cross-matched and
|
|
2744
|
+
// moved directly — no key map, no sources array, no LIS. A bigger shuffle falls
|
|
2745
|
+
// through to the general map phase; the in-place updates already done stay valid
|
|
2746
|
+
// there, since the map phase finds those rows already matching their new items.
|
|
2747
|
+
let fastHandled = false;
|
|
2748
|
+
if (oldRemain === newRemain) {
|
|
2749
|
+
let displaced = null;
|
|
2750
|
+
let ok = true;
|
|
2751
|
+
for (let i=start; i<newEnd; i++) {
|
|
2752
|
+
let ng = oldNgs[i], t = newItems[i];
|
|
2753
|
+
if (ng.template === t) {
|
|
2754
|
+
if (ng.shell.needsRefresh)
|
|
2755
|
+
this.refreshSameItem(ng, t);
|
|
2756
|
+
}
|
|
2757
|
+
else {
|
|
2758
|
+
let k = keyOf(t);
|
|
2759
|
+
if (k !== undefined && ng.key === k && itemClose(ng, t)) {
|
|
2760
|
+
if (itemSame(ng, t))
|
|
2761
|
+
this.refreshSameItem(ng, t);
|
|
2762
|
+
else
|
|
2763
|
+
this.rewriteNodeGroup(ng, t);
|
|
2764
|
+
}
|
|
2765
|
+
else {
|
|
2766
|
+
(displaced ??= []).push(i);
|
|
2767
|
+
if (displaced.length > 8) {
|
|
2768
|
+
ok = false;
|
|
2769
|
+
break;
|
|
2770
|
+
}
|
|
2771
|
+
continue; // newNgs[i] is filled during the placement pass below.
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
newNgs[i] = ng;
|
|
2775
|
+
}
|
|
2776
|
+
if (ok) {
|
|
2777
|
+
// The windows are the same length, so a displaced row's index is already its
|
|
2778
|
+
// position and no position map is needed. The tail anchor is the suffix row
|
|
2779
|
+
// just past this window, which placement never writes to — it only fills
|
|
2780
|
+
// positions below newEnd — so it is computed once here instead of on every
|
|
2781
|
+
// pass around the placement loop.
|
|
2782
|
+
if (displaced !== null)
|
|
2783
|
+
this.placeDisplaced(displaced, null, oldNgs, newItems, newNgs, newEnd,
|
|
2784
|
+
newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker),
|
|
2785
|
+
parent);
|
|
2786
|
+
fastHandled = true;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
if (!fastHandled) {
|
|
2791
|
+
|
|
2000
2792
|
// 3. Match the middle windows by key.
|
|
2001
2793
|
let kept = 0, moved = false;
|
|
2002
2794
|
let sources = null; // sources[i] = old index reused by new item start+i, or -1 to create fresh.
|
|
@@ -2022,71 +2814,82 @@ class PathToNodes extends Path {
|
|
|
2022
2814
|
moved = true;
|
|
2023
2815
|
else
|
|
2024
2816
|
lastNewIndex = newIndex;
|
|
2025
|
-
if (itemSame(ng, t))
|
|
2026
|
-
|
|
2027
|
-
ng.applyExprs(t.exprs, false);
|
|
2028
|
-
}
|
|
2817
|
+
if (itemSame(ng, t))
|
|
2818
|
+
this.refreshSameItem(ng, t);
|
|
2029
2819
|
else
|
|
2030
2820
|
this.rewriteNodeGroup(ng, t);
|
|
2031
2821
|
newNgs[newIndex] = ng;
|
|
2032
2822
|
}
|
|
2033
2823
|
else
|
|
2034
2824
|
(removals ??= []).push(ng);
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
else {
|
|
2038
|
-
removals = oldNgs.slice(start, oldEnd);
|
|
2825
|
+
}
|
|
2039
2826
|
}
|
|
2827
|
+
// else: the whole old window goes away. It isn't collected into an array here,
|
|
2828
|
+
// because the fast clear below usually takes every one of them at once and the
|
|
2829
|
+
// array would be built only to be thrown away.
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
// 3b. A large whole-parent list that is being fully replaced is emptied and refilled
|
|
2833
|
+
// with its parent detached, so the browser's connected-tree bookkeeping (child-change
|
|
2834
|
+
// notifications, tree-version bumps, MutationObserver interest walks, deferred
|
|
2835
|
+
// accessibility and style consumers) runs once at reattach instead of once per row
|
|
2836
|
+
// removed and once per row added. Detaching before the clear, rather than after it,
|
|
2837
|
+
// puts the removals on the cheap side of that line as well. The gates: the whole
|
|
2838
|
+
// region is being replaced, so nothing is kept and no focus can survive inside it;
|
|
2839
|
+
// the parent is a plain element, since detaching a custom element would fire its
|
|
2840
|
+
// disconnected/connectedCallback in the middle of a render and a subclass may run
|
|
2841
|
+
// arbitrary logic there; the parent is in the document, since the notification storm
|
|
2842
|
+
// only exists on a connected tree; and the list is long enough for the saving to beat
|
|
2843
|
+
// the fixed cost of the detour and the extra MutationObserver records it creates.
|
|
2844
|
+
let detachedFrom = null, reattachBefore = null;
|
|
2845
|
+
if (wholeParent && start === 0 && newEnd === newLen && kept === 0 && newRemain > 500
|
|
2846
|
+
&& parent.isConnected && parent.parentNode !== null
|
|
2847
|
+
&& parent.localName.indexOf('-') === -1 && !parent.hasAttribute('is')) {
|
|
2848
|
+
detachedFrom = parent.parentNode;
|
|
2849
|
+
reattachBefore = parent.nextSibling;
|
|
2850
|
+
parent.remove();
|
|
2040
2851
|
}
|
|
2041
2852
|
|
|
2042
2853
|
// 4. Remove unmatched old NodeGroups. They're discarded, never pooled,
|
|
2043
2854
|
// so a later render with new keys always creates new nodes.
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2855
|
+
let removeAll = oldRemain !== 0 && newRemain === 0;
|
|
2856
|
+
if (removals !== null || removeAll) {
|
|
2857
|
+
// Fast clear when nothing is kept anywhere; the whole region is removals. Trying
|
|
2858
|
+
// it first means a cleared list skips the two passes below entirely: those exist
|
|
2859
|
+
// to lift each group's nodes out one at a time, and emptying the parent has
|
|
2860
|
+
// already taken all of them.
|
|
2861
|
+
if (!(start === 0 && newEnd === newLen && kept === 0 && this.fastClear())) {
|
|
2862
|
+
if (removeAll)
|
|
2863
|
+
removals = oldNgs.slice(start, oldEnd);
|
|
2864
|
+
|
|
2865
|
+
// Materialize node caches of multi-node groups while attached, since detaching breaks sibling links.
|
|
2866
|
+
for (let ng of removals)
|
|
2867
|
+
if (ng.startNode !== ng.endNode)
|
|
2868
|
+
ng.getNodes();
|
|
2049
2869
|
|
|
2050
|
-
// Fast clear when nothing is kept anywhere; the whole region is removals.
|
|
2051
|
-
let cleared = start === 0 && newEnd === newLen && kept === 0 && this.fastClear();
|
|
2052
|
-
if (!cleared)
|
|
2053
2870
|
for (let ng of removals) {
|
|
2054
2871
|
if (ng.startNode !== ng.endNode)
|
|
2055
2872
|
Util.saveOrphans(ng.getNodes()); // Moves the nodes out of the DOM, into their own fragment.
|
|
2056
2873
|
else
|
|
2057
2874
|
ng.startNode.remove();
|
|
2058
2875
|
}
|
|
2876
|
+
}
|
|
2059
2877
|
}
|
|
2060
2878
|
|
|
2061
2879
|
// 5. Insert new NodeGroups and move kept ones.
|
|
2062
2880
|
if (newRemain) {
|
|
2063
2881
|
let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
|
|
2064
2882
|
|
|
2065
|
-
// 5a. Nothing kept in the middle:
|
|
2883
|
+
// 5a. Nothing kept in the middle: insert every new item directly.
|
|
2884
|
+
// Each row is one native insert; routing rows through a batching
|
|
2885
|
+
// DocumentFragment would double the insert count for no benefit, since
|
|
2886
|
+
// style/layout work is deferred until the next frame either way.
|
|
2066
2887
|
if (kept === 0) {
|
|
2067
|
-
let target = parent, before = anchor;
|
|
2068
|
-
let fragment = null;
|
|
2069
|
-
if (newRemain > 1) {
|
|
2070
|
-
fragment = Globals$1.doc.createDocumentFragment();
|
|
2071
|
-
target = fragment;
|
|
2072
|
-
before = null;
|
|
2073
|
-
}
|
|
2074
2888
|
for (let i=start; i<newEnd; i++) {
|
|
2075
2889
|
let ng = this.createNew(newItems[i]);
|
|
2076
2890
|
newNgs[i] = ng;
|
|
2077
|
-
|
|
2078
|
-
if (node === end)
|
|
2079
|
-
target.insertBefore(node, before);
|
|
2080
|
-
else while (true) {
|
|
2081
|
-
let next = node.nextSibling;
|
|
2082
|
-
target.insertBefore(node, before);
|
|
2083
|
-
if (node === end)
|
|
2084
|
-
break;
|
|
2085
|
-
node = next;
|
|
2086
|
-
}
|
|
2891
|
+
insertNodesBefore(parent, ng, anchor);
|
|
2087
2892
|
}
|
|
2088
|
-
if (fragment)
|
|
2089
|
-
parent.insertBefore(fragment, anchor);
|
|
2090
2893
|
}
|
|
2091
2894
|
|
|
2092
2895
|
// 5b. Mixed: iterate backwards so each item's anchor is already in place.
|
|
@@ -2113,6 +2916,11 @@ class PathToNodes extends Path {
|
|
|
2113
2916
|
}
|
|
2114
2917
|
}
|
|
2115
2918
|
|
|
2919
|
+
if (detachedFrom !== null)
|
|
2920
|
+
detachedFrom.insertBefore(parent, reattachBefore);
|
|
2921
|
+
|
|
2922
|
+
} // end if (!fastHandled)
|
|
2923
|
+
|
|
2116
2924
|
// 6. Node membership or order changed, so invalidate caches.
|
|
2117
2925
|
// During a NodeGroup's first applyExprs(), no ancestor caches can reference its nodes yet.
|
|
2118
2926
|
if (!this.parentNg.firstApply) {
|
|
@@ -2125,8 +2933,6 @@ class PathToNodes extends Path {
|
|
|
2125
2933
|
this.nodeGroups = newNgs;
|
|
2126
2934
|
|
|
2127
2935
|
// Keep state used by the generic path from going stale.
|
|
2128
|
-
if (this.nodeGroupsRendered)
|
|
2129
|
-
this.nodeGroupsRendered = null;
|
|
2130
2936
|
if (this.nodeGroupsAttachedAvailable)
|
|
2131
2937
|
this.nodeGroupsAttachedAvailable = null;
|
|
2132
2938
|
}
|
|
@@ -2140,11 +2946,30 @@ class PathToNodes extends Path {
|
|
|
2140
2946
|
if (typeof item === 'string')
|
|
2141
2947
|
return new NodeGroup(textTemplate(item), this); // Text NodeGroups have no paths to apply.
|
|
2142
2948
|
let ng = new NodeGroup(item, this);
|
|
2949
|
+
if (ng.shell.needsRefresh)
|
|
2950
|
+
this.anyNeedsRefresh = true;
|
|
2143
2951
|
if (item.exprs.length || (ng.paths && ng.paths.length))
|
|
2144
2952
|
ng.applyExprs(item.exprs);
|
|
2145
2953
|
return ng;
|
|
2146
2954
|
}
|
|
2147
2955
|
|
|
2956
|
+
/**
|
|
2957
|
+
* Refresh a NodeGroup whose new template has the SAME values as its current one.
|
|
2958
|
+
* Components still render so changes deeper in the tree can surface, and groups holding
|
|
2959
|
+
* live-HTML-property bindings (checked/value/selected) rewrite in place — a user's click
|
|
2960
|
+
* flips those DOM properties underneath the cached expression, so same values ≠ same DOM.
|
|
2961
|
+
* rewriteNodeGroup's per-path skip exempts exactly those paths; everything else is
|
|
2962
|
+
* compared and skipped as before, so this stays cheap.
|
|
2963
|
+
* @param ng {NodeGroup}
|
|
2964
|
+
* @param t {Template|string} */
|
|
2965
|
+
refreshSameItem(ng, t) {
|
|
2966
|
+
let shell = ng.shell;
|
|
2967
|
+
if (shell.hasComponentPaths)
|
|
2968
|
+
ng.applyExprs(t.exprs, false);
|
|
2969
|
+
else if (shell.hasLivePropPaths && shell.pathsSingleExpr && typeof t !== 'string')
|
|
2970
|
+
this.rewriteNodeGroup(ng, t);
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2148
2973
|
/**
|
|
2149
2974
|
* Update an existing NodeGroup, created from the same html strings, with new values.
|
|
2150
2975
|
* @param ng {NodeGroup}
|
|
@@ -2158,15 +2983,21 @@ class PathToNodes extends Path {
|
|
|
2158
2983
|
else {
|
|
2159
2984
|
// When every path consumes exactly one expression, paths align 1:1 with exprs,
|
|
2160
2985
|
// so only the expressions that changed need to be applied.
|
|
2161
|
-
if (ng.pathsSingleExpr) {
|
|
2986
|
+
if (ng.shell.pathsSingleExpr) {
|
|
2162
2987
|
// Stamped groups (paths === null) rewrite through the shared stampers and stay
|
|
2163
2988
|
// path-less, unless a child-node expression stopped being primitive.
|
|
2164
2989
|
if (ng.paths !== null || !ng.rewriteStamp(item)) {
|
|
2165
2990
|
let oldExprs = ng.template.exprs, newExprs = item.exprs;
|
|
2166
2991
|
let paths = ng.paths ?? ng.materializePaths();
|
|
2167
|
-
for (let i = paths.length - 1; i >= 0; i--)
|
|
2168
|
-
|
|
2169
|
-
|
|
2992
|
+
for (let i = paths.length - 1; i >= 0; i--) {
|
|
2993
|
+
// Boolean live-HTML-property bindings are exempt from the unchanged-value
|
|
2994
|
+
// skip — a click flips the property underneath the cached expression;
|
|
2995
|
+
// applySingle() compares against the live node before writing.
|
|
2996
|
+
let oldExpr = oldExprs[i], newExpr = newExprs[i];
|
|
2997
|
+
if ((oldExpr !== newExpr && !exprSame(oldExpr, newExpr))
|
|
2998
|
+
|| (paths[i].isHtmlProperty && typeof newExpr === 'boolean'))
|
|
2999
|
+
paths[i].applySingle(newExpr);
|
|
3000
|
+
}
|
|
2170
3001
|
}
|
|
2171
3002
|
|
|
2172
3003
|
if (ng.styles)
|
|
@@ -2205,6 +3036,8 @@ class PathToNodes extends Path {
|
|
|
2205
3036
|
}
|
|
2206
3037
|
|
|
2207
3038
|
ng = new NodeGroup(item, this);
|
|
3039
|
+
if (ng.shell.needsRefresh)
|
|
3040
|
+
this.anyNeedsRefresh = true;
|
|
2208
3041
|
if (item.exprs.length || (ng.paths && ng.paths.length))
|
|
2209
3042
|
ng.applyExprs(item.exprs);
|
|
2210
3043
|
return ng;
|
|
@@ -2232,6 +3065,14 @@ class PathToNodes extends Path {
|
|
|
2232
3065
|
else if (typeof expr === 'function')
|
|
2233
3066
|
hasNodes = this.collectItems(expr(), items, hasNodes);
|
|
2234
3067
|
|
|
3068
|
+
// A MappedList nested inside an array or returned from a function can't use the
|
|
3069
|
+
// identity fast path, but it still renders; expand it through the per-item cache.
|
|
3070
|
+
else if (expr instanceof MappedList) {
|
|
3071
|
+
let subItems = expr.items, fn = expr.fn;
|
|
3072
|
+
for (let i=0; i<subItems.length; i++)
|
|
3073
|
+
items.push(fn(subItems[i]));
|
|
3074
|
+
}
|
|
3075
|
+
|
|
2235
3076
|
else if (expr instanceof NodeList) {
|
|
2236
3077
|
for (let node of expr)
|
|
2237
3078
|
items.push(node);
|
|
@@ -2271,7 +3112,7 @@ class PathToNodes extends Path {
|
|
|
2271
3112
|
/** @type {Node[]} */
|
|
2272
3113
|
let newNodes = [];
|
|
2273
3114
|
let oldNodeGroups = path.nodeGroups || emptyNodeGroups;
|
|
2274
|
-
/*#
|
|
3115
|
+
/*#IFDEBUG*/assert(!oldNodeGroups.includes(null));/*#ENDIF*/
|
|
2275
3116
|
|
|
2276
3117
|
path.nodeGroups = [];
|
|
2277
3118
|
for (let item of items) {
|
|
@@ -2379,29 +3220,26 @@ class PathToNodes extends Path {
|
|
|
2379
3220
|
|| this.nodeGroupsDetachedAvailable?.deleteAny(closeKey);
|
|
2380
3221
|
|
|
2381
3222
|
if (result) {
|
|
2382
|
-
if (templatesSame(result.template, template))
|
|
2383
|
-
|
|
2384
|
-
if (result.hasComponentPaths)
|
|
2385
|
-
result.applyExprs(template.exprs, false);
|
|
2386
|
-
}
|
|
3223
|
+
if (templatesSame(result.template, template))
|
|
3224
|
+
this.refreshSameItem(result, template);
|
|
2387
3225
|
else
|
|
2388
3226
|
result.applyExprs(template.exprs);
|
|
2389
3227
|
result.template = template;
|
|
2390
3228
|
}
|
|
2391
3229
|
else {
|
|
2392
3230
|
result = new NodeGroup(template, this);
|
|
3231
|
+
if (result.shell.needsRefresh)
|
|
3232
|
+
this.anyNeedsRefresh = true;
|
|
2393
3233
|
result.applyExprs(template.exprs);
|
|
2394
3234
|
}
|
|
2395
3235
|
|
|
2396
|
-
(
|
|
2397
|
-
|
|
2398
|
-
/*#IFDEV*/assert(result.parentPath);/*#ENDIF*/
|
|
3236
|
+
/*#IFDEBUG*/assert(result.parentPath);/*#ENDIF*/
|
|
2399
3237
|
return result;
|
|
2400
3238
|
}
|
|
2401
3239
|
|
|
2402
3240
|
|
|
2403
3241
|
/**
|
|
2404
|
-
* Move everything from this.
|
|
3242
|
+
* Move everything from this.nodeGroups to this.nodeGroupsAttached and nodeGroupsDetached.
|
|
2405
3243
|
* Called at the beginning of applyGeneric() so it can have NodeGroups to use.
|
|
2406
3244
|
* TODO: this could run as needed in getNodeGroup? */
|
|
2407
3245
|
freeNodeGroups() {
|
|
@@ -2411,7 +3249,7 @@ class PathToNodes extends Path {
|
|
|
2411
3249
|
let detached = (this.nodeGroupsDetachedAvailable ??= new MultiValueMap()).data;
|
|
2412
3250
|
for (let key in previouslyAttached) {
|
|
2413
3251
|
let src = previouslyAttached[key];
|
|
2414
|
-
let from = src.
|
|
3252
|
+
let from = src.hd || 0; // Skip entries already consumed by deleteAny().
|
|
2415
3253
|
let array = detached[key];
|
|
2416
3254
|
if (!array) {
|
|
2417
3255
|
array = detached[key] = from ? src.slice(from) : src;
|
|
@@ -2419,22 +3257,18 @@ class PathToNodes extends Path {
|
|
|
2419
3257
|
array.length = maxPooledPerKey;
|
|
2420
3258
|
}
|
|
2421
3259
|
else
|
|
2422
|
-
for (let i=from, max=maxPooledPerKey + (array.
|
|
3260
|
+
for (let i=from, max=maxPooledPerKey + (array.hd || 0); i<src.length && array.length < max; i++)
|
|
2423
3261
|
array.push(src[i]);
|
|
2424
3262
|
}
|
|
2425
3263
|
}
|
|
2426
3264
|
|
|
2427
|
-
//
|
|
2428
|
-
//
|
|
2429
|
-
// this.nodeGroups
|
|
2430
|
-
this.nodeGroupsAttachedAvailable = new MultiValueMap();
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
if (source)
|
|
2434
|
-
for (let ng of source)
|
|
3265
|
+
// Offer the NodeGroups the last render left in place for reuse. Every path that renders
|
|
3266
|
+
// NodeGroups — the positional diff, the keyed diff and applyGeneric alike — leaves them in
|
|
3267
|
+
// this.nodeGroups, so that one array is always the set still standing in the DOM.
|
|
3268
|
+
let nga = this.nodeGroupsAttachedAvailable = new MultiValueMap();
|
|
3269
|
+
if (this.nodeGroups)
|
|
3270
|
+
for (let ng of this.nodeGroups)
|
|
2435
3271
|
nga.add(ng.closeKey, ng);
|
|
2436
|
-
|
|
2437
|
-
this.nodeGroupsRendered = null;
|
|
2438
3272
|
}
|
|
2439
3273
|
|
|
2440
3274
|
|
|
@@ -2454,7 +3288,7 @@ class PathToNodes extends Path {
|
|
|
2454
3288
|
// This shaves about 5ms off the partialUpdate benchmark.
|
|
2455
3289
|
result = this.nodesCache;
|
|
2456
3290
|
if (result) {
|
|
2457
|
-
//#
|
|
3291
|
+
//#IFDEBUG
|
|
2458
3292
|
//this.checkNodesCache();
|
|
2459
3293
|
//#ENDIF
|
|
2460
3294
|
return result
|
|
@@ -2477,7 +3311,7 @@ class PathToNodes extends Path {
|
|
|
2477
3311
|
return result;
|
|
2478
3312
|
}
|
|
2479
3313
|
|
|
2480
|
-
//#
|
|
3314
|
+
//#IFDEBUG
|
|
2481
3315
|
|
|
2482
3316
|
get debug() {
|
|
2483
3317
|
return [
|
|
@@ -2511,12 +3345,55 @@ class PathToNodes extends Path {
|
|
|
2511
3345
|
// Shared empty array for paths whose nodeGroups were never created. Never mutated.
|
|
2512
3346
|
const emptyNodeGroups = [];
|
|
2513
3347
|
|
|
3348
|
+
// How many changed h.map() positions applyMapped() collects before it stops to work out what
|
|
3349
|
+
// kind of change it is looking at (see the probe in applyMapped). Below this every ordinary
|
|
3350
|
+
// edit — a selection, a partial update — is handled without asking.
|
|
3351
|
+
const missProbeThreshold = 256;
|
|
3352
|
+
|
|
3353
|
+
// How many of those positions may need matching against each other before the general keyed
|
|
3354
|
+
// diff, with its key map and longest-increasing-subsequence, becomes the cheaper tool. The
|
|
3355
|
+
// cross-match here is quadratic, which only pays while the number of moved rows is small.
|
|
3356
|
+
const maxDisplacedMisses = 16;
|
|
3357
|
+
|
|
3358
|
+
// How far applyMapped() looks around a position to pick a shifted list's rows back up. One
|
|
3359
|
+
// insertion or removal moves everything by one, which the first step finds; a handful at once
|
|
3360
|
+
// still lands inside this window, and past it the item map takes over.
|
|
3361
|
+
const shiftSearchDistance = 4;
|
|
3362
|
+
|
|
3363
|
+
// How far ahead it looks to recognize a block of inserted rows, by finding the item that the
|
|
3364
|
+
// old row standing here now belongs to. Wider than the search above because inserting a page
|
|
3365
|
+
// of rows at once is ordinary, and because this search only runs while the walk is still in
|
|
3366
|
+
// step and stops it dead the first time it fails — so its worst case is one pass of this many
|
|
3367
|
+
// comparisons per render, against building a map of every row in the list.
|
|
3368
|
+
const insertSearchDistance = 64;
|
|
3369
|
+
|
|
2514
3370
|
// Most detached NodeGroups kept per close key. Bounds memory growth after very large
|
|
2515
3371
|
// lists are cleared while keeping pooled rows for every typical re-create pattern.
|
|
2516
3372
|
// Lowering this (e.g. to 1000) cuts retained memory ~7x after clearing a 10k-row list,
|
|
2517
3373
|
// but makes re-creating such a list ~2x slower since most rows are built fresh.
|
|
2518
3374
|
const maxPooledPerKey = 10000;
|
|
2519
3375
|
|
|
3376
|
+
|
|
3377
|
+
// Cache for keyOf(): list rows share one html array, so the Shell lookup that finds where the
|
|
3378
|
+
// key=${} expression sits happens once per list rather than once per row.
|
|
3379
|
+
let lastKeyHtml = null, lastKeyIndex = -1;
|
|
3380
|
+
|
|
3381
|
+
/**
|
|
3382
|
+
* The list key of an item, or undefined when it has none.
|
|
3383
|
+
* @param t {Template|string}
|
|
3384
|
+
* @return {*} */
|
|
3385
|
+
function keyOf(t) {
|
|
3386
|
+
if (typeof t === 'string')
|
|
3387
|
+
return undefined;
|
|
3388
|
+
if (t.key !== undefined) // JSX templates carry the key directly.
|
|
3389
|
+
return t.key;
|
|
3390
|
+
if (t.html !== lastKeyHtml) {
|
|
3391
|
+
lastKeyHtml = t.html;
|
|
3392
|
+
lastKeyIndex = Shell.get(t.html, t.svgMode).keyIndex;
|
|
3393
|
+
}
|
|
3394
|
+
return lastKeyIndex >= 0 ? t.exprs[lastKeyIndex] : undefined;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
2520
3397
|
/**
|
|
2521
3398
|
* @param text {string}
|
|
2522
3399
|
* @return {Template} */
|
|
@@ -2553,6 +3430,21 @@ function itemClose(ng, item) {
|
|
|
2553
3430
|
return tpl.html === item.html && tpl.svgMode === item.svgMode;
|
|
2554
3431
|
}
|
|
2555
3432
|
|
|
3433
|
+
/**
|
|
3434
|
+
* Is this item somewhere in the list the previous render drew, i.e. did it move rather than
|
|
3435
|
+
* appear? A plain scan rather than a map, because it runs once and usually answers on the way
|
|
3436
|
+
* past.
|
|
3437
|
+
* @param lastItems {Array}
|
|
3438
|
+
* @param oldLen {int}
|
|
3439
|
+
* @param item {*}
|
|
3440
|
+
* @return {boolean} */
|
|
3441
|
+
function itemIsElsewhere(lastItems, oldLen, item) {
|
|
3442
|
+
for (let i=0; i<oldLen; i++)
|
|
3443
|
+
if (lastItems[i] === item)
|
|
3444
|
+
return true;
|
|
3445
|
+
return false;
|
|
3446
|
+
}
|
|
3447
|
+
|
|
2556
3448
|
/**
|
|
2557
3449
|
* Insert all of ng's nodes before anchor within parent.
|
|
2558
3450
|
* @param parent {Node}
|
|
@@ -2649,12 +3541,6 @@ function reconcileNodes(parentNode, oldNodes, newNodes, before) {
|
|
|
2649
3541
|
* matches NodeGroups to new templates by this key. */
|
|
2650
3542
|
class PathToKey extends Path {
|
|
2651
3543
|
|
|
2652
|
-
/**
|
|
2653
|
-
* @param exprs {Expr[]} Only the first is used. */
|
|
2654
|
-
apply(exprs) {
|
|
2655
|
-
this.parentNg.key = exprs[0];
|
|
2656
|
-
}
|
|
2657
|
-
|
|
2658
3544
|
applySingle(expr) {
|
|
2659
3545
|
this.parentNg.key = expr;
|
|
2660
3546
|
}
|
|
@@ -2676,15 +3562,15 @@ class PathToComponent extends Path {
|
|
|
2676
3562
|
* Call render() on the component pointed to by this Path.
|
|
2677
3563
|
* And instantiate it (from a -solarite-placeholder element) if it hasn't been done yet.
|
|
2678
3564
|
* @param exprs {Expr[][]} Expressions to evaluate for each attribute to pass to the constructor.
|
|
2679
|
-
* This is different than other Path.
|
|
3565
|
+
* This is different than other Path.applyAll() functions which only receive Expr[] and not Expr[][].
|
|
2680
3566
|
* Because here we're receiving an array of arrays of expressions, one for each dynamic attribute. */
|
|
2681
|
-
|
|
2682
|
-
//#
|
|
3567
|
+
applyAll(exprs) {
|
|
3568
|
+
//#IFDEBUG
|
|
2683
3569
|
assert(Array.isArray(exprs));
|
|
2684
3570
|
assert(!exprs.length || Array.isArray(exprs[0]));
|
|
2685
3571
|
//#ENDIF
|
|
2686
3572
|
|
|
2687
|
-
//#
|
|
3573
|
+
//#IFDEBUG
|
|
2688
3574
|
assert(exprs.length === this.attribPaths.length);
|
|
2689
3575
|
//#ENDIF
|
|
2690
3576
|
|
|
@@ -2700,8 +3586,15 @@ class PathToComponent extends Path {
|
|
|
2700
3586
|
for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
|
|
2701
3587
|
if (attribPath instanceof PathToKey) // The list key is never a component arg.
|
|
2702
3588
|
continue;
|
|
3589
|
+
// Event attributes like onchange=${...} are bound with addEventListener when the
|
|
3590
|
+
// PathToEvent itself is applied. They must not also become constructor fields:
|
|
3591
|
+
// a component that assigns its fields onto itself would set the native on*
|
|
3592
|
+
// property, making the handler fire a second time with only the (event) argument
|
|
3593
|
+
// instead of Solarite's documented (event, element) signature.
|
|
3594
|
+
if (attribPath instanceof PathToEvent)
|
|
3595
|
+
continue;
|
|
2703
3596
|
if (attribPath instanceof PathToAttribValue) {
|
|
2704
|
-
let name = Util.dashesToCamel(attribPath.
|
|
3597
|
+
let name = Util.dashesToCamel(attribPath.attribName);
|
|
2705
3598
|
|
|
2706
3599
|
// Resolve two way bindimg path before we pass it to the component.
|
|
2707
3600
|
let value = attribPath.getValue(exprs[i]);
|
|
@@ -2723,85 +3616,120 @@ class PathToComponent extends Path {
|
|
|
2723
3616
|
}
|
|
2724
3617
|
}
|
|
2725
3618
|
|
|
2726
|
-
//
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
3619
|
+
// Constructing a component runs arbitrary user code -- field initializers, the
|
|
3620
|
+
// constructor body, render() -- and that code can build more components, re-entering
|
|
3621
|
+
// this method and overwriting the hand-off parked below. Saving the caller's value
|
|
3622
|
+
// here and restoring it in the finally makes the JS call stack the stack this hand-off
|
|
3623
|
+
// needs, and unlike an explicit stack it cannot leak if construction throws.
|
|
3624
|
+
let prevSlotChildren = Globals$1.currentSlotChildren;
|
|
3625
|
+
try {
|
|
3626
|
+
// 2. Instantiate component on first time.
|
|
3627
|
+
let isAttrib = el.getAttribute('_is');
|
|
3628
|
+
if (el.tagName.endsWith('-SOLARITE-PLACEHOLDER') || isAttrib) {
|
|
3629
|
+
|
|
3630
|
+
|
|
3631
|
+
// 2a. Instantiate component
|
|
3632
|
+
let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
|
|
3633
|
+
let Constructor = customElements.get(tagName);
|
|
3634
|
+
|
|
3635
|
+
// Not defined yet (e.g. the module is being lazily imported): keep the placeholder
|
|
3636
|
+
// and instantiate when the definition lands, like a native custom-element upgrade.
|
|
3637
|
+
// deferredExprs always holds the LATEST exprs so re-renders while undefined win.
|
|
3638
|
+
if (!Constructor) {
|
|
3639
|
+
this.deferredExprs = exprs;
|
|
3640
|
+
if (!this.whenDefinedPending) {
|
|
3641
|
+
this.whenDefinedPending = true;
|
|
3642
|
+
console.warn(`Solarite: <${tagName}> is not defined yet; waiting for customElements.define().`);
|
|
3643
|
+
customElements.whenDefined(tagName).then(() => {
|
|
3644
|
+
this.whenDefinedPending = false;
|
|
3645
|
+
let deferred = this.deferredExprs;
|
|
3646
|
+
this.deferredExprs = null;
|
|
3647
|
+
// Skip if a newer render already instantiated or replaced the placeholder.
|
|
3648
|
+
if (deferred && this.nodeMarker === el && el.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
|
|
3649
|
+
this.applyAll(deferred);
|
|
3650
|
+
});
|
|
3651
|
+
}
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
2730
3654
|
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
3655
|
+
// Hand the children declared inside the component's tag to the RootNodeGroup that
|
|
3656
|
+
// its render() is about to create. There is no other channel: the children have
|
|
3657
|
+
// to be parked before new Constructor(), because a Solarite constructor may call
|
|
3658
|
+
// this.render() itself, and the element that would otherwise carry them does not
|
|
3659
|
+
// exist yet.
|
|
3660
|
+
Globals$1.currentSlotChildren = {Constructor, nodes: [...el.childNodes]};
|
|
3661
|
+
let newEl = new Constructor(attribs);
|
|
3662
|
+
|
|
3663
|
+
// 2b. Copy attributes over.
|
|
3664
|
+
if (isAttrib) {
|
|
3665
|
+
newEl.setAttribute('is', isAttrib);
|
|
3666
|
+
// el.removeAttribute('_is');
|
|
3667
|
+
}
|
|
3668
|
+
for (let attrib of el.attributes)
|
|
3669
|
+
if (attrib.name !== '_is')
|
|
3670
|
+
newEl.setAttribute(attrib.name, attrib.value);
|
|
2736
3671
|
|
|
2737
|
-
|
|
2738
|
-
|
|
3672
|
+
// Set dynamic attributes if they are primitive types.
|
|
3673
|
+
for (let name in attribs) {
|
|
3674
|
+
let val = attribs[name];
|
|
3675
|
+
let valType = typeof val;
|
|
3676
|
+
// Only true and false can reach here, so the undefined/null halves of the
|
|
3677
|
+
// falsy test this used to spell out could never have decided anything.
|
|
3678
|
+
if (valType === 'boolean') {
|
|
3679
|
+
if (val)
|
|
3680
|
+
newEl.setAttribute(name, '');
|
|
3681
|
+
}
|
|
2739
3682
|
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
// el.removeAttribute('_is');
|
|
2744
|
-
}
|
|
2745
|
-
for (let attrib of el.attributes)
|
|
2746
|
-
if (attrib.name !== '_is')
|
|
2747
|
-
newEl.setAttribute(attrib.name, attrib.value);
|
|
2748
|
-
|
|
2749
|
-
// Set dynamic attributes if they are primitive types.
|
|
2750
|
-
for (let name in attribs) {
|
|
2751
|
-
let val = attribs[name];
|
|
2752
|
-
let valType = typeof val;
|
|
2753
|
-
if (valType === 'boolean') {
|
|
2754
|
-
if (val !== false && val !== undefined && val !== null) // Util.isFalsy() inlined
|
|
2755
|
-
newEl.setAttribute(name, '');
|
|
3683
|
+
// If type is a non-boolean primitive, set the attribute value.
|
|
3684
|
+
else if (valType==='string' || valType === 'number' || valType==='bigint')
|
|
3685
|
+
newEl.setAttribute(name, val);
|
|
2756
3686
|
}
|
|
2757
3687
|
|
|
2758
|
-
// If type is a non-boolean primitive, set the attribute value.
|
|
2759
|
-
else if (valType==='string' || valType === 'number' || valType==='bigint')
|
|
2760
|
-
newEl.setAttribute(name, val);
|
|
2761
|
-
}
|
|
2762
3688
|
|
|
3689
|
+
// 2c. If an id pointed at the placeholder, update it to point to the new element.
|
|
3690
|
+
let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
|
|
3691
|
+
if (id)
|
|
3692
|
+
delve(this.parentNg.getRootEl(), id.split(/\./g), newEl);
|
|
2763
3693
|
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
3694
|
+
// 2d. Update paths to use replaced element.
|
|
3695
|
+
let ng = this.parentNg;
|
|
3696
|
+
this.nodeMarker = newEl;
|
|
3697
|
+
for (let path of ng.paths) {
|
|
3698
|
+
if (path.nodeMarker === el)
|
|
3699
|
+
path.nodeMarker = newEl;
|
|
3700
|
+
if (path.nodeBefore === el)
|
|
3701
|
+
path.nodeBefore = newEl;
|
|
3702
|
+
}
|
|
3703
|
+
if (ng.startNode === el)
|
|
3704
|
+
ng.startNode = newEl;
|
|
3705
|
+
if (ng.endNode === el)
|
|
3706
|
+
ng.endNode = newEl;
|
|
3707
|
+
|
|
3708
|
+
// 2f. Call render() if it wasn't called by the constructor.
|
|
3709
|
+
// This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
|
|
3710
|
+
// Because that path renders it without the attribute expressions.
|
|
3711
|
+
if (typeof newEl.render === 'function' && !Globals$1.rendered.has(newEl))
|
|
3712
|
+
newEl.render(attribs, true);
|
|
3713
|
+
|
|
3714
|
+
// 2g. Update attribute paths to use the new element and re-apply them.
|
|
3715
|
+
for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
|
|
3716
|
+
attribPath.parentNg = this.parentNg;
|
|
3717
|
+
attribPath.nodeMarker = newEl;
|
|
3718
|
+
attribPath.applyAll(exprs[i]);
|
|
3719
|
+
}
|
|
2768
3720
|
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
this.nodeMarker = newEl;
|
|
2772
|
-
for (let path of ng.paths) {
|
|
2773
|
-
if (path.nodeMarker === el)
|
|
2774
|
-
path.nodeMarker = newEl;
|
|
2775
|
-
if (path.nodeBefore === el)
|
|
2776
|
-
path.nodeBefore = newEl;
|
|
3721
|
+
// 2e. Swap it to the DOM.
|
|
3722
|
+
el.replaceWith(newEl);
|
|
2777
3723
|
}
|
|
2778
|
-
if (ng.startNode === el)
|
|
2779
|
-
ng.startNode = newEl;
|
|
2780
|
-
if (ng.endNode === el)
|
|
2781
|
-
ng.endNode = newEl;
|
|
2782
|
-
|
|
2783
|
-
// 2f. Call render() if it wasn't called by the constructor.
|
|
2784
|
-
// This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
|
|
2785
|
-
// Because that path renders it without the attribute expressions.
|
|
2786
|
-
if (typeof newEl.render === 'function' && !Globals$1.rendered.has(newEl))
|
|
2787
|
-
newEl.render(attribs, true);
|
|
2788
|
-
|
|
2789
|
-
// 2g. Update attribute paths to use the new element and re-apply them.
|
|
2790
|
-
for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
|
|
2791
|
-
attribPath.parentNg = this.parentNg;
|
|
2792
|
-
attribPath.nodeMarker = newEl;
|
|
2793
|
-
attribPath.apply(exprs[i]);
|
|
2794
|
-
}
|
|
2795
|
-
|
|
2796
|
-
// 2e. Swap it to the DOM.
|
|
2797
|
-
el.replaceWith(newEl);
|
|
2798
|
-
}
|
|
2799
3724
|
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
3725
|
+
// 2f. Render
|
|
3726
|
+
else if (typeof el.render === 'function')
|
|
3727
|
+
el.render(attribs, changed);
|
|
2803
3728
|
|
|
2804
|
-
|
|
3729
|
+
}
|
|
3730
|
+
finally {
|
|
3731
|
+
Globals$1.currentSlotChildren = prevSlotChildren;
|
|
3732
|
+
}
|
|
2805
3733
|
}
|
|
2806
3734
|
|
|
2807
3735
|
/**
|
|
@@ -2809,21 +3737,16 @@ class PathToComponent extends Path {
|
|
|
2809
3737
|
* @param pathOffset {int}
|
|
2810
3738
|
* @return {Path} */
|
|
2811
3739
|
clone(newRoot, pathOffset=0) {
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
let result =
|
|
3740
|
+
// A component path's nodeBefore is always null (the constructor discards it), so the
|
|
3741
|
+
// base clone() resolves only the nodeMarker and hands back a new PathToComponent.
|
|
3742
|
+
let result = super.clone(newRoot, pathOffset);
|
|
2815
3743
|
result.attribPaths = this.attribPaths.map(path => path.clone(newRoot, pathOffset));
|
|
2816
|
-
|
|
2817
|
-
//#IFDEV
|
|
2818
|
-
result.verify();
|
|
2819
|
-
//#ENDIF
|
|
2820
|
-
|
|
2821
3744
|
return result;
|
|
2822
3745
|
}
|
|
2823
3746
|
|
|
2824
3747
|
getExpressionCount() { return 0 }
|
|
2825
3748
|
|
|
2826
|
-
//#
|
|
3749
|
+
//#IFDEBUG
|
|
2827
3750
|
verify() {
|
|
2828
3751
|
super.verify();
|
|
2829
3752
|
assert(this.nodeMarker.nodeType === Node.ELEMENT_NODE);
|
|
@@ -2847,7 +3770,7 @@ class Shell {
|
|
|
2847
3770
|
|
|
2848
3771
|
/**
|
|
2849
3772
|
* @type {DocumentFragment|Text} DOM parent of the shell's nodes. */
|
|
2850
|
-
|
|
3773
|
+
docFrag;
|
|
2851
3774
|
|
|
2852
3775
|
/** @type {Path[]} Paths to where expressions should go. */
|
|
2853
3776
|
paths = [];
|
|
@@ -2867,10 +3790,22 @@ class Shell {
|
|
|
2867
3790
|
/** @type {boolean} True if any of this Shell's own paths is a PathToComponent. */
|
|
2868
3791
|
hasComponentPaths = false;
|
|
2869
3792
|
|
|
3793
|
+
/** @type {boolean} True if any path binds an attribute that's a live HTML property
|
|
3794
|
+
* (checked, value, selected — Util.isHtmlProp). Users flip those underneath the template,
|
|
3795
|
+
* so "expression unchanged" doesn't mean "DOM unchanged" and the skip shortcuts exempt them. */
|
|
3796
|
+
hasLivePropPaths = false;
|
|
3797
|
+
|
|
2870
3798
|
/** @type {boolean} True if every path consumes exactly one expression and none are components.
|
|
2871
3799
|
* Lets NodeGroup.applyExprs() use a fast loop without allocating per-path expression arrays. */
|
|
2872
3800
|
pathsSingleExpr = false;
|
|
2873
3801
|
|
|
3802
|
+
/** @type {boolean} True when a NodeGroup whose values are unchanged still has work to do:
|
|
3803
|
+
* components re-render so changes deeper in the tree surface, and live HTML properties are
|
|
3804
|
+
* rewritten because a click can flip them underneath the cached expression. The list scans
|
|
3805
|
+
* check this before calling PathToNodes.refreshSameItem(), so the overwhelmingly common
|
|
3806
|
+
* unchanged row costs one field read instead of a call. */
|
|
3807
|
+
needsRefresh = false;
|
|
3808
|
+
|
|
2874
3809
|
/** @type {boolean} True if this Shell has any ids, styles, or scripts. */
|
|
2875
3810
|
hasEmbeds = false;
|
|
2876
3811
|
|
|
@@ -2886,6 +3821,52 @@ class Shell {
|
|
|
2886
3821
|
* with no per-instance Path objects. See the stampPaths setup in the constructor. */
|
|
2887
3822
|
stampable = false;
|
|
2888
3823
|
|
|
3824
|
+
// The remaining fields are only filled in for some shells (resolve program, stampable),
|
|
3825
|
+
// but they're all declared here so every Shell instance shares one hidden class.
|
|
3826
|
+
// NodeGroup's per-row code (its constructor, applyStamp, resolveStampSlots) reads these
|
|
3827
|
+
// off whichever shell it's given, and a single shape keeps those loads monomorphic.
|
|
3828
|
+
|
|
3829
|
+
/** @type {?string} The Template close key, cached here by the NodeGroup constructor so
|
|
3830
|
+
* each new template row skips a WeakMap lookup. See Template.getCloseKey(). */
|
|
3831
|
+
closeKey;
|
|
3832
|
+
|
|
3833
|
+
/** @type {?int[]} The resolve program: flat [parentSlot, childIndex] pairs in dependency
|
|
3834
|
+
* order; pair i fills slot i+1, slot 0 being the fragment. Built by buildResolveProgram();
|
|
3835
|
+
* undefined for shells with components. */
|
|
3836
|
+
resolveOps;
|
|
3837
|
+
|
|
3838
|
+
/** @type {?Node[]} Reusable scratch array for resolved nodes; safe because resolution
|
|
3839
|
+
* never re-enters. */
|
|
3840
|
+
resolveSlots;
|
|
3841
|
+
|
|
3842
|
+
// The stamp program, set only when stampable is true:
|
|
3843
|
+
|
|
3844
|
+
/** @type {?int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
|
|
3845
|
+
nodesPathIdx;
|
|
3846
|
+
|
|
3847
|
+
/** @type {?Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
|
|
3848
|
+
stampPaths;
|
|
3849
|
+
|
|
3850
|
+
/** @type {?Uint8Array} Opcode per path; see the stamp-program comment in the constructor. */
|
|
3851
|
+
stampOp;
|
|
3852
|
+
|
|
3853
|
+
/** @type {?Uint16Array} paths[i].markerSlot, in a flat array so the hot loop
|
|
3854
|
+
* doesn't load the Path object to find its slot. */
|
|
3855
|
+
stampSlot;
|
|
3856
|
+
|
|
3857
|
+
/** @type {?Path[]} Per-path extra the stamp program needs: the event stamper for op 3
|
|
3858
|
+
* (it carries delegatedKey and eventName), the attribute name for op 4, null otherwise. */
|
|
3859
|
+
stampAux;
|
|
3860
|
+
|
|
3861
|
+
/** @type {?string[]} The delegatable event names this shell binds, so a loop can register
|
|
3862
|
+
* their dispatchers once for the whole run of rows instead of testing every bound node. */
|
|
3863
|
+
stampEventNames;
|
|
3864
|
+
|
|
3865
|
+
/** @type {?Uint8Array} Per-path flags the in-place rewrite loop needs, so it reads one byte
|
|
3866
|
+
* from a flat array instead of two properties from a Path object it otherwise wouldn't
|
|
3867
|
+
* touch. Bit 1 = the path binds a live HTML property, bit 2 = it's a whole-parent child. */
|
|
3868
|
+
stampFlags;
|
|
3869
|
+
|
|
2889
3870
|
/**
|
|
2890
3871
|
* Create the nodes but without filling in the expressions.
|
|
2891
3872
|
* This is useful because the expression-less nodes created by a template can be cached.
|
|
@@ -2895,13 +3876,13 @@ class Shell {
|
|
|
2895
3876
|
if (!html)
|
|
2896
3877
|
return;
|
|
2897
3878
|
|
|
2898
|
-
//#
|
|
3879
|
+
//#IFDEBUG
|
|
2899
3880
|
this._html = html.join('');
|
|
2900
3881
|
//#ENDIF
|
|
2901
3882
|
|
|
2902
3883
|
// If no html tags or entities, just create a text node.
|
|
2903
3884
|
if (html.length === 1 && !html[0].match(/[<&]/)) {
|
|
2904
|
-
this.
|
|
3885
|
+
this.docFrag = Globals$1.doc.createTextNode(html[0]);
|
|
2905
3886
|
return;
|
|
2906
3887
|
}
|
|
2907
3888
|
|
|
@@ -2919,29 +3900,32 @@ class Shell {
|
|
|
2919
3900
|
let frag = Globals$1.doc.createDocumentFragment();
|
|
2920
3901
|
while (svgEl.firstChild)
|
|
2921
3902
|
frag.append(svgEl.firstChild);
|
|
2922
|
-
this.
|
|
3903
|
+
this.docFrag = frag;
|
|
2923
3904
|
}
|
|
2924
3905
|
else {
|
|
2925
3906
|
template.innerHTML = htmlWithPlaceholders;
|
|
2926
|
-
this.
|
|
3907
|
+
this.docFrag = template.content;
|
|
2927
3908
|
}
|
|
2928
3909
|
}
|
|
2929
3910
|
else { // Create one text node, so shell isn't empty and NodeGroups created from it have something to point the startNode and endNode at.
|
|
2930
3911
|
template.content.append(Globals$1.doc.createTextNode(''));
|
|
2931
|
-
this.
|
|
3912
|
+
this.docFrag = template.content;
|
|
2932
3913
|
}
|
|
2933
3914
|
|
|
2934
3915
|
// 1b. Remove whitespace-only text nodes inside table-structure elements.
|
|
2935
3916
|
// The parser foster-parents non-whitespace text out of tables, and whitespace-only
|
|
2936
3917
|
// text between cells/rows is never rendered, so removing it is invisible.
|
|
2937
3918
|
// Smaller fragments make cloning, path resolution, and insertion faster.
|
|
2938
|
-
stripTableWhitespace(this.
|
|
3919
|
+
stripTableWhitespace(this.docFrag);
|
|
3920
|
+
|
|
3921
|
+
// 1c. Neutralize `is` so the browser can't upgrade a placeholder out from under us.
|
|
3922
|
+
renameIsAttribs(this.docFrag);
|
|
2939
3923
|
|
|
2940
3924
|
// 2. Find placeholders
|
|
2941
3925
|
let node;
|
|
2942
3926
|
let toRemove = [];
|
|
2943
3927
|
let placeholdersUsed = 0;
|
|
2944
|
-
const walker = Globals$1.doc.createTreeWalker(this.
|
|
3928
|
+
const walker = Globals$1.doc.createTreeWalker(this.docFrag, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
|
|
2945
3929
|
while (node = walker.nextNode()) {
|
|
2946
3930
|
|
|
2947
3931
|
// Remove previous elements after each iteration, so paths will still be calculated correctly.
|
|
@@ -2950,7 +3934,7 @@ class Shell {
|
|
|
2950
3934
|
|
|
2951
3935
|
// Replace attributes
|
|
2952
3936
|
if (node.nodeType === 1) {
|
|
2953
|
-
const hasIs = node.hasAttribute('
|
|
3937
|
+
const hasIs = node.hasAttribute('_is'); // Renamed from `is` in step 1c.
|
|
2954
3938
|
const isComponent = (hasIs || node.tagName.includes('-'));
|
|
2955
3939
|
const componentAttribPaths = [];
|
|
2956
3940
|
|
|
@@ -2959,13 +3943,20 @@ class Shell {
|
|
|
2959
3943
|
// The reserved key attribute identifies this template within a keyed list.
|
|
2960
3944
|
// It's consumed here and never written to the DOM or passed to components.
|
|
2961
3945
|
if (attr.name === 'key') {
|
|
3946
|
+
|
|
3947
|
+
// These three are template-authoring mistakes, and every one of them fails SILENTLY if
|
|
3948
|
+
// it isn't caught: the reconciler would key rows on a garbage value and reuse the wrong
|
|
3949
|
+
// DOM, with nothing reported. So they ship, unlike the assertions elsewhere in this
|
|
3950
|
+
// file. The cost is one regex split per unique template \u2014 never per render, never per
|
|
3951
|
+
// row \u2014 which is why they are affordable to keep.
|
|
2962
3952
|
let parts = attr.value.split(/[\ue000-\uf8ff]/g);
|
|
2963
3953
|
if (parts.length !== 2 || parts[0] !== '' || parts[1] !== '')
|
|
2964
|
-
throw new Error(`
|
|
2965
|
-
if (node.parentNode !== this.
|
|
2966
|
-
throw new Error(`
|
|
3954
|
+
throw new Error(`Solarite: key must be one whole expression.`);
|
|
3955
|
+
if (node.parentNode !== this.docFrag)
|
|
3956
|
+
throw new Error(`Solarite: key must be on a top-level element.`);
|
|
2967
3957
|
if (this.keyIndex >= 0)
|
|
2968
|
-
throw new Error(`
|
|
3958
|
+
throw new Error(`Solarite: duplicate key attribute.`);
|
|
3959
|
+
|
|
2969
3960
|
this.keyIndex = attr.value.charCodeAt(0) - attribPlaceholder;
|
|
2970
3961
|
|
|
2971
3962
|
let path = new PathToKey(null, node);
|
|
@@ -3010,19 +4001,33 @@ class Shell {
|
|
|
3010
4001
|
}
|
|
3011
4002
|
|
|
3012
4003
|
placeholdersUsed += parts.length - 1;
|
|
3013
|
-
//
|
|
3014
|
-
//
|
|
3015
|
-
//
|
|
3016
|
-
//
|
|
3017
|
-
//
|
|
3018
|
-
|
|
4004
|
+
// An attribute whose whole value is one expression is removed from the shell:
|
|
4005
|
+
// its stamped value is always the empty string, so every clone would carry a
|
|
4006
|
+
// useless empty attribute that costs storage on creation and a slot in the
|
|
4007
|
+
// element's attribute list forever, and apply() writes the real value anyway
|
|
4008
|
+
// (a missing attribute reads back as '', so an empty expression still writes
|
|
4009
|
+
// nothing). Event attributes must be removed for the same reason plus a
|
|
4010
|
+
// stricter one: an empty onclick="" violates a strict CSP when the event fires.
|
|
4011
|
+
// In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the
|
|
4012
|
+
// placeholders stripped out makes the browser log parse errors, both here and
|
|
4013
|
+
// when the fragment is cloned, so those are removed whether or not they're whole.
|
|
4014
|
+
if (svgMode || !nonEmptyParts)
|
|
3019
4015
|
node.removeAttribute(attr.name);
|
|
3020
|
-
|
|
4016
|
+
|
|
4017
|
+
// setAttribute throws only when the template author wrote a name the browser
|
|
4018
|
+
// refuses, such as one holding a space or a quote. That name comes from a tagged
|
|
4019
|
+
// template literal's static text, so it is a typo that surfaces the first time the
|
|
4020
|
+
// template renders and can never appear later or for only some users. Development
|
|
4021
|
+
// therefore wraps the call to rethrow with the attribute name and the tag included,
|
|
4022
|
+
// because the browser's own DOMException names neither and leaves the author
|
|
4023
|
+
// hunting. Production ships the bare call and lets that DOMException through: the
|
|
4024
|
+
// friendlier wording is only worth its bytes to whoever can still fix the template.
|
|
4025
|
+
else /*#IFDEBUG*/try {/*#ENDIF*/
|
|
3021
4026
|
node.setAttribute(attr.name, parts.join(''));
|
|
3022
|
-
}
|
|
4027
|
+
/*#IFDEBUG*/}
|
|
3023
4028
|
catch (e) {
|
|
3024
4029
|
throw new Error(`Error setting attribute "${attr.name}" on node <${node.tagName}>: ${e.message}`);
|
|
3025
|
-
}
|
|
4030
|
+
}/*#ENDIF*/
|
|
3026
4031
|
}
|
|
3027
4032
|
}
|
|
3028
4033
|
}
|
|
@@ -3033,10 +4038,6 @@ class Shell {
|
|
|
3033
4038
|
path.attribPaths = componentAttribPaths;
|
|
3034
4039
|
this.paths.splice(this.paths.length - componentAttribPaths.length, 0, path); // Insert before its componentAttribPaths
|
|
3035
4040
|
|
|
3036
|
-
if (hasIs) {
|
|
3037
|
-
node.setAttribute('_is', node.getAttribute('is'));
|
|
3038
|
-
node.removeAttribute('is');
|
|
3039
|
-
}
|
|
3040
4041
|
}
|
|
3041
4042
|
}
|
|
3042
4043
|
|
|
@@ -3044,7 +4045,7 @@ class Shell {
|
|
|
3044
4045
|
else if (node.nodeType === 8 && node.nodeValue === '!✨!') {
|
|
3045
4046
|
|
|
3046
4047
|
if (node?.parentNode?.closest && node?.parentNode?.closest('[contenteditable]'))
|
|
3047
|
-
throw new Error(`
|
|
4048
|
+
throw new Error(`Solarite: no \${...} inside contenteditable; use value="\${...}".`);
|
|
3048
4049
|
|
|
3049
4050
|
let parent = node.parentNode;
|
|
3050
4051
|
|
|
@@ -3053,7 +4054,7 @@ class Shell {
|
|
|
3053
4054
|
// Components and slots are excluded because they move their children
|
|
3054
4055
|
// during instantiation, which would orphan the expression's region.
|
|
3055
4056
|
if (parent.nodeType === 1 && !node.previousSibling && !node.nextSibling
|
|
3056
|
-
&& !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('
|
|
4057
|
+
&& !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('_is')) {
|
|
3057
4058
|
let path = new PathToNodes(null, parent);
|
|
3058
4059
|
path.wholeParent = true;
|
|
3059
4060
|
this.paths.push(path);
|
|
@@ -3068,7 +4069,7 @@ class Shell {
|
|
|
3068
4069
|
nodeBefore = Globals$1.doc.createComment('Path:'+this.paths.length);
|
|
3069
4070
|
node.parentNode.insertBefore(nodeBefore, node);
|
|
3070
4071
|
}
|
|
3071
|
-
/*#
|
|
4072
|
+
/*#IFDEBUG*/assert(nodeBefore);/*#ENDIF*/
|
|
3072
4073
|
|
|
3073
4074
|
// Get the next node.
|
|
3074
4075
|
let nodeMarker;
|
|
@@ -3083,7 +4084,7 @@ class Shell {
|
|
|
3083
4084
|
nodeMarker = node;
|
|
3084
4085
|
nodeMarker.textContent = 'PathEnd:'+ this.paths.length;
|
|
3085
4086
|
}
|
|
3086
|
-
/*#
|
|
4087
|
+
/*#IFDEBUG*/assert(nodeMarker);/*#ENDIF*/
|
|
3087
4088
|
|
|
3088
4089
|
let path = new PathToNodes(nodeBefore, nodeMarker);
|
|
3089
4090
|
this.paths.push(path);
|
|
@@ -3091,11 +4092,6 @@ class Shell {
|
|
|
3091
4092
|
}
|
|
3092
4093
|
}
|
|
3093
4094
|
|
|
3094
|
-
// Comments become text nodes when inside textareas.
|
|
3095
|
-
else if (node.nodeType === 3 && node.parentNode?.tagName === 'TEXTAREA' && node.textContent.includes('<!--!✨!-->'))
|
|
3096
|
-
throw new Error(`Textarea can't have expressions inside them. Use <textarea value="\${...}"> instead.`);
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
4095
|
// Sometimes users will comment out a block of html code that has expressions.
|
|
3100
4096
|
// Here we look for expressions in comments.
|
|
3101
4097
|
// We don't actually update them dynamically, but we still add paths for them.
|
|
@@ -3109,29 +4105,39 @@ class Shell {
|
|
|
3109
4105
|
}
|
|
3110
4106
|
}
|
|
3111
4107
|
|
|
3112
|
-
//
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
4108
|
+
// A few elements have raw-text bodies, which the html parser reads as literal characters
|
|
4109
|
+
// rather than as markup. A comment placeholder written inside one therefore never becomes
|
|
4110
|
+
// a comment node; it arrives here as ordinary text. A textarea can't support expressions
|
|
4111
|
+
// in its body at all, while script and style can, by splitting their text around each
|
|
4112
|
+
// placeholder so that every expression gets a text node of its own to write into.
|
|
4113
|
+
else if (node.nodeType === 3) { // Node.TEXT_NODE
|
|
4114
|
+
let parentName = node.parentNode?.nodeName;
|
|
4115
|
+
|
|
4116
|
+
if (parentName === 'TEXTAREA' && node.textContent.includes(commentPlaceholder))
|
|
4117
|
+
throw new Error(`Solarite: no \${...} inside textarea; use value="\${...}".`);
|
|
4118
|
+
|
|
4119
|
+
else if (parentName === 'SCRIPT' || parentName === 'STYLE') {
|
|
4120
|
+
let parts = node.textContent.split(commentPlaceholder);
|
|
4121
|
+
if (parts.length > 1) {
|
|
4122
|
+
|
|
4123
|
+
// Every part is inserted before the original node, in order, so from the second
|
|
4124
|
+
// part onward the text node made on the previous iteration is already sitting
|
|
4125
|
+
// immediately before this one and serves as the new path's nodeBefore.
|
|
4126
|
+
for (let i = 0; i<parts.length; i++) {
|
|
4127
|
+
let current = Globals$1.doc.createTextNode(parts[i]);
|
|
4128
|
+
node.parentNode.insertBefore(current, node);
|
|
4129
|
+
if (i > 0) {
|
|
4130
|
+
let path = new PathToNodes(current.previousSibling, current);
|
|
4131
|
+
this.paths.push(path);
|
|
4132
|
+
placeholdersUsed ++;
|
|
4133
|
+
|
|
4134
|
+
/*#IFDEBUG*/path.verify();/*#ENDIF*/
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
3129
4137
|
|
|
3130
|
-
|
|
4138
|
+
// Removing it here will mess up the treeWalker.
|
|
4139
|
+
toRemove.push(node);
|
|
3131
4140
|
}
|
|
3132
|
-
|
|
3133
|
-
// Removing them here will mess up the treeWalker.
|
|
3134
|
-
toRemove.push(node);
|
|
3135
4141
|
}
|
|
3136
4142
|
}
|
|
3137
4143
|
}
|
|
@@ -3140,31 +4146,37 @@ class Shell {
|
|
|
3140
4146
|
// Less than or equal because there can be one path to multiple expressions
|
|
3141
4147
|
// if those expressions are in the same attribute value.
|
|
3142
4148
|
if (placeholdersUsed !== html.length-1)
|
|
3143
|
-
throw new Error(`
|
|
4149
|
+
throw new Error(`Solarite: bad html or duplicate attribute: ${html.join('${...}')}`);
|
|
3144
4150
|
|
|
3145
4151
|
for (let path of this.paths) {
|
|
3146
|
-
|
|
3147
|
-
|
|
4152
|
+
// -1 when the path has no nodeBefore. Assigned unconditionally so every shell path
|
|
4153
|
+
// of a given class takes the same property-addition order and shares one hidden class.
|
|
4154
|
+
path.nodeBeforeIndex = path.nodeBefore
|
|
4155
|
+
? Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
|
|
4156
|
+
: -1;
|
|
3148
4157
|
|
|
3149
4158
|
// Must be calculated after we remove the toRemove nodes:
|
|
3150
4159
|
path.nodeMarkerPath = Path.get(path.nodeMarker);
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
4160
|
}
|
|
3154
4161
|
|
|
3155
4162
|
this.findEmbeds();
|
|
3156
|
-
this.buildResolveProgram();
|
|
3157
4163
|
|
|
4164
|
+
// This scan must run before buildResolveProgram(), which skips shells with components
|
|
4165
|
+
// and reads hasComponentPaths rather than walking the paths a second time.
|
|
3158
4166
|
this.pathsSingleExpr = true;
|
|
3159
4167
|
for (let path of this.paths) {
|
|
3160
4168
|
if (path instanceof PathToComponent) {
|
|
3161
4169
|
this.hasComponentPaths = true;
|
|
3162
4170
|
this.pathsSingleExpr = false;
|
|
3163
|
-
break; // Both facts are now decided.
|
|
3164
4171
|
}
|
|
3165
|
-
if (path.getExpressionCount() !== 1)
|
|
3166
|
-
this.pathsSingleExpr = false;
|
|
4172
|
+
else if (path.getExpressionCount() !== 1)
|
|
4173
|
+
this.pathsSingleExpr = false;
|
|
4174
|
+
if (path.isHtmlProperty) // needs the full scan — no early break
|
|
4175
|
+
this.hasLivePropPaths = true;
|
|
3167
4176
|
}
|
|
4177
|
+
this.needsRefresh = this.hasComponentPaths || (this.hasLivePropPaths && this.pathsSingleExpr);
|
|
4178
|
+
|
|
4179
|
+
this.buildResolveProgram();
|
|
3168
4180
|
|
|
3169
4181
|
// Stampable shells create NodeGroups without allocating any Path objects:
|
|
3170
4182
|
// NodeGroup.applyStamp() writes expressions through these shared stamper paths,
|
|
@@ -3189,17 +4201,50 @@ class Shell {
|
|
|
3189
4201
|
}
|
|
3190
4202
|
if (ok) {
|
|
3191
4203
|
this.stampable = true;
|
|
3192
|
-
|
|
3193
|
-
/** @type {int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
|
|
3194
4204
|
this.nodesPathIdx = nodesIdx;
|
|
3195
|
-
|
|
3196
|
-
/** @type {Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
|
|
3197
4205
|
this.stampPaths = this.paths.map(p => p.cloneWithNodes(null, p.nodeMarker));
|
|
3198
4206
|
|
|
4207
|
+
// Compiled stamp program: one opcode per path lets applyStamp() write a fresh
|
|
4208
|
+
// row through a flat branch chain instead of dispatching applySingle() per path.
|
|
4209
|
+
// 0 = generic (shared stamper fallback), 1 = list key (no DOM), 2 = wholeParent
|
|
4210
|
+
// child text, 3 = delegatable single-expression event (written as node expandos
|
|
4211
|
+
// when the root delegates, the default).
|
|
4212
|
+
let n = this.paths.length;
|
|
4213
|
+
this.stampOp = new Uint8Array(n);
|
|
4214
|
+
this.stampSlot = new Uint16Array(n);
|
|
4215
|
+
this.stampAux = new Array(n).fill(null);
|
|
4216
|
+
this.stampFlags = new Uint8Array(n);
|
|
4217
|
+
|
|
4218
|
+
let eventNames = null;
|
|
4219
|
+
for (let i=0; i<n; i++) {
|
|
4220
|
+
let p = this.paths[i], sp = this.stampPaths[i];
|
|
4221
|
+
this.stampSlot[i] = p.markerSlot;
|
|
4222
|
+
this.stampFlags[i] = (sp.isHtmlProperty ? 1 : 0) | (sp.wholeParent ? 2 : 0);
|
|
4223
|
+
if (p instanceof PathToKey)
|
|
4224
|
+
this.stampOp[i] = 1;
|
|
4225
|
+
else if (sp.wholeParent)
|
|
4226
|
+
this.stampOp[i] = 2;
|
|
4227
|
+
else if (sp instanceof PathToEvent && sp.delegatedKey !== undefined && !sp.attrValue) {
|
|
4228
|
+
this.stampOp[i] = 3;
|
|
4229
|
+
this.stampAux[i] = sp;
|
|
4230
|
+
(eventNames ??= []).push(sp.eventName);
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
// A plain attribute holding one whole expression. The shell no longer carries
|
|
4234
|
+
// the attribute at all (see the placeholder handling above), so on a freshly
|
|
4235
|
+
// cloned row the value is known to be absent and a string can be written
|
|
4236
|
+
// without first reading back what's there.
|
|
4237
|
+
else if (sp instanceof PathToAttribValue && !sp.attrValue && !sp.isHtmlProperty
|
|
4238
|
+
&& !sp.isComponentAttrib) {
|
|
4239
|
+
this.stampOp[i] = 4;
|
|
4240
|
+
this.stampAux[i] = sp.attribName;
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
this.stampEventNames = eventNames;
|
|
3199
4244
|
}
|
|
3200
4245
|
}
|
|
3201
4246
|
|
|
3202
|
-
/*#
|
|
4247
|
+
/*#IFDEBUG*/this.verify();/*#ENDIF*/
|
|
3203
4248
|
}
|
|
3204
4249
|
|
|
3205
4250
|
/**
|
|
@@ -3210,42 +4255,64 @@ class Shell {
|
|
|
3210
4255
|
* @param htmlChunks {string[]}
|
|
3211
4256
|
* @returns {string} Html with the placeholders in place. */
|
|
3212
4257
|
static addPlaceholders(htmlChunks) {
|
|
3213
|
-
let result =
|
|
4258
|
+
let result = '';
|
|
4259
|
+
|
|
4260
|
+
// Where the tokenizer is as it walks the chunks. An expression can sit in the middle of an attribute
|
|
4261
|
+
// value, so both of these have to survive from one chunk to the next. Nothing else has to: an
|
|
4262
|
+
// expression anywhere inside a tag gets the same attribute placeholder, so the machine only has to
|
|
4263
|
+
// know whether it is inside a tag at all, and whether a quoted value is currently open.
|
|
4264
|
+
let inTag = false; // True from the '<' that opens a tag or comment through the '>' that closes it.
|
|
4265
|
+
let quote = null; // The quote character that opened the attribute value we're inside of: null, '"', or "'".
|
|
3214
4266
|
|
|
3215
|
-
let htmlParser = new HtmlParser(); // Reset the context.
|
|
3216
4267
|
for (let i = 0; i < htmlChunks.length; i++) {
|
|
3217
|
-
let
|
|
4268
|
+
let html = htmlChunks[i];
|
|
3218
4269
|
|
|
3219
4270
|
// Append -solarite-placholder to web component tags, so we can pass args to them when they're instantiated.
|
|
3220
|
-
let lastIndex = 0;
|
|
3221
|
-
let
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
if (
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
//
|
|
3229
|
-
//
|
|
3230
|
-
|
|
3231
|
-
|
|
4271
|
+
let lastIndex = 0; // Start of the run of this chunk not yet copied into result.
|
|
4272
|
+
for (let j = 0; j < html.length; j++) {
|
|
4273
|
+
const char = html[j];
|
|
4274
|
+
|
|
4275
|
+
if (!inTag) {
|
|
4276
|
+
if (char === '<' && html[j + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
|
|
4277
|
+
inTag = true;
|
|
4278
|
+
|
|
4279
|
+
// A component suffix can only ever be added right here, at the '<' that opens the tag, so
|
|
4280
|
+
// the name is matched on the spot with a sticky regex rather than collected into a buffer
|
|
4281
|
+
// and matched later. The greedy tag-name class can't run past the name, because every
|
|
4282
|
+
// character that can follow a tag name is outside it.
|
|
4283
|
+
isWebComponentTagName.lastIndex = j;
|
|
4284
|
+
let match = isWebComponentTagName.exec(html);
|
|
4285
|
+
if (match) {
|
|
4286
|
+
let end = j + match[0].length;
|
|
4287
|
+
result += html.slice(lastIndex, end) + '-SOLARITE-PLACEHOLDER';
|
|
4288
|
+
lastIndex = end;
|
|
4289
|
+
}
|
|
3232
4290
|
}
|
|
4291
|
+
}
|
|
3233
4292
|
|
|
3234
|
-
|
|
4293
|
+
// Inside a tag, only two characters end anything: the quote that closes the value we're in, or,
|
|
4294
|
+
// when we're not in one, the '>' that closes the tag. Attribute names, '=', unquoted values and
|
|
4295
|
+
// whitespace all need no handling at all.
|
|
4296
|
+
else if (quote) {
|
|
4297
|
+
if (char === quote)
|
|
4298
|
+
quote = null;
|
|
3235
4299
|
}
|
|
3236
|
-
|
|
3237
|
-
|
|
4300
|
+
else if (char === '"' || char === "'")
|
|
4301
|
+
quote = char;
|
|
4302
|
+
else if (char === '>')
|
|
4303
|
+
inTag = false;
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
result += html.slice(lastIndex);
|
|
3238
4307
|
|
|
3239
4308
|
// Insert placeholders
|
|
3240
|
-
if (i < htmlChunks.length - 1)
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
result.push(String.fromCharCode(attribPlaceholder + i));
|
|
3245
|
-
}
|
|
4309
|
+
if (i < htmlChunks.length - 1)
|
|
4310
|
+
result += inTag
|
|
4311
|
+
? String.fromCharCode(attribPlaceholder + i)
|
|
4312
|
+
: commentPlaceholder; // Comment Placeholder. because we can't put text in between <tr> tags for example.
|
|
3246
4313
|
}
|
|
3247
4314
|
|
|
3248
|
-
return result
|
|
4315
|
+
return result;
|
|
3249
4316
|
}
|
|
3250
4317
|
|
|
3251
4318
|
/**
|
|
@@ -3257,21 +4324,18 @@ class Shell {
|
|
|
3257
4324
|
* this.ids
|
|
3258
4325
|
* this.staticComponents */
|
|
3259
4326
|
findEmbeds() {
|
|
3260
|
-
this.scripts = Array.prototype.map.call(this.
|
|
4327
|
+
this.scripts = Array.prototype.map.call(this.docFrag.querySelectorAll('script'), el => Path.get(el));
|
|
3261
4328
|
|
|
3262
4329
|
// TODO: only find styles that have Paths in them?
|
|
3263
|
-
this.styles = Array.prototype.map.call(this.
|
|
3264
|
-
|
|
3265
|
-
let idEls = this.fragment.querySelectorAll('[id],[data-id]');
|
|
4330
|
+
this.styles = Array.prototype.map.call(this.docFrag.querySelectorAll('style'), el => Path.get(el));
|
|
3266
4331
|
|
|
3267
|
-
//
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
this.ids = Array.prototype.map.call(idEls, el => Path.get(el));
|
|
4332
|
+
// An id that would clobber a built-in element property is reported by Util.bindId(), which
|
|
4333
|
+
// asks the real component object, with `in`, at the moment the binding happens. The check
|
|
4334
|
+
// that used to stand here asked Globals.div.hasOwnProperty(id) instead, and a freshly
|
|
4335
|
+
// created element has no own properties at all — every DOM property an element exposes
|
|
4336
|
+
// lives on its interface prototype — so that test could never be true and the error it
|
|
4337
|
+
// guarded was never reachable.
|
|
4338
|
+
this.ids = Array.prototype.map.call(this.docFrag.querySelectorAll('[id],[data-id]'), el => Path.get(el));
|
|
3275
4339
|
|
|
3276
4340
|
this.hasEmbeds = this.ids.length > 0 || this.styles.length > 0 || this.scripts.length > 0;
|
|
3277
4341
|
}
|
|
@@ -3282,25 +4346,37 @@ class Shell {
|
|
|
3282
4346
|
* Replaces per-path root-to-node walks in the hot NodeGroup creation path.
|
|
3283
4347
|
* Skipped for shells with components, whose clone() has special attribPaths behavior. */
|
|
3284
4348
|
buildResolveProgram() {
|
|
3285
|
-
|
|
3286
|
-
for (let path of this.paths)
|
|
3287
|
-
if (path instanceof PathToComponent) {
|
|
3288
|
-
hasComponents = true;
|
|
3289
|
-
break;
|
|
3290
|
-
}
|
|
3291
|
-
if (hasComponents || !this.paths.length)
|
|
4349
|
+
if (this.hasComponentPaths || !this.paths.length)
|
|
3292
4350
|
return;
|
|
3293
4351
|
|
|
3294
4352
|
let ops = [];
|
|
3295
4353
|
let slotOf = new Map();
|
|
3296
|
-
let frag = this.
|
|
4354
|
+
let frag = this.docFrag;
|
|
3297
4355
|
let nextSlot = 1;
|
|
3298
4356
|
let getSlot = node => {
|
|
3299
4357
|
if (node === frag)
|
|
3300
4358
|
return 0;
|
|
3301
4359
|
let s = slotOf.get(node);
|
|
3302
4360
|
if (s === undefined) {
|
|
3303
|
-
|
|
4361
|
+
// Two ways to reach a node, costing one pointer step each: walk forward from an
|
|
4362
|
+
// already-resolved earlier sibling, or take the parent's firstChild and walk
|
|
4363
|
+
// forward. Sibling steps win whenever they're no more numerous, and they can
|
|
4364
|
+
// also spare the parent a slot of its own — in a row of cells, resolving each
|
|
4365
|
+
// <td> from the previous one is one step instead of firstChild plus its index.
|
|
4366
|
+
let d = 0, from = -1;
|
|
4367
|
+
for (let sib = node.previousSibling; sib; sib = sib.previousSibling) {
|
|
4368
|
+
d++;
|
|
4369
|
+
let ss = slotOf.get(sib);
|
|
4370
|
+
if (ss !== undefined) {
|
|
4371
|
+
from = ss;
|
|
4372
|
+
break;
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
let index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
|
|
4376
|
+
if (from >= 0 && d <= index + 1)
|
|
4377
|
+
ops.push(from, -d); // A negative step count means "walk nextSibling from that slot".
|
|
4378
|
+
else
|
|
4379
|
+
ops.push(getSlot(node.parentNode), index);
|
|
3304
4380
|
s = nextSlot++;
|
|
3305
4381
|
slotOf.set(node, s);
|
|
3306
4382
|
}
|
|
@@ -3311,10 +4387,7 @@ class Shell {
|
|
|
3311
4387
|
path.beforeSlot = path.nodeBefore ? getSlot(path.nodeBefore) : -1;
|
|
3312
4388
|
}
|
|
3313
4389
|
|
|
3314
|
-
/** @type {?int[]} Flat [parentSlot, childIndex] pairs; pair i fills slot i+1. */
|
|
3315
4390
|
this.resolveOps = ops;
|
|
3316
|
-
|
|
3317
|
-
/** @type {Node[]} Reusable scratch array for resolved nodes; safe because resolution never re-enters. */
|
|
3318
4391
|
this.resolveSlots = new Array(nextSlot);
|
|
3319
4392
|
|
|
3320
4393
|
// A lone root element means slot 1 is always that element (the first op pair is [0, 0]),
|
|
@@ -3349,15 +4422,15 @@ class Shell {
|
|
|
3349
4422
|
lastSvgMode = svgMode;
|
|
3350
4423
|
lastShell = result;
|
|
3351
4424
|
|
|
3352
|
-
/*#
|
|
4425
|
+
/*#IFDEBUG*/result.verify();/*#ENDIF*/
|
|
3353
4426
|
return result;
|
|
3354
4427
|
}
|
|
3355
4428
|
|
|
3356
|
-
//#
|
|
4429
|
+
//#IFDEBUG
|
|
3357
4430
|
// For debugging only:
|
|
3358
4431
|
verify() {
|
|
3359
4432
|
for (let path of this.paths) {
|
|
3360
|
-
assert(this.
|
|
4433
|
+
assert(this.docFrag.contains(path.getParentNode()));
|
|
3361
4434
|
path.verify();
|
|
3362
4435
|
}
|
|
3363
4436
|
}
|
|
@@ -3367,6 +4440,15 @@ class Shell {
|
|
|
3367
4440
|
|
|
3368
4441
|
const commentPlaceholder = `<!--!✨!-->`;
|
|
3369
4442
|
|
|
4443
|
+
// A tag name with a dash in the middle, which is what makes an element a web component. addPlaceholders()
|
|
4444
|
+
// tests this at each '<' that opens a tag, and a match gets -solarite-placeholder appended to its tag name.
|
|
4445
|
+
// That way we can gather a component's constructor arguments and its children before we call its constructor;
|
|
4446
|
+
// later PathToComponent.applyAll() replaces the placeholder tag with the real component. The suffix is written in
|
|
4447
|
+
// caps wherever it appears, so that the several copies of it in this project compress well. It's sticky rather
|
|
4448
|
+
// than anchored so it can be tested at an offset within the chunk instead of against a sliced-out token.
|
|
4449
|
+
// Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
|
|
4450
|
+
const isWebComponentTagName = /<\/?[a-z][a-z0-9]*-[a-z0-9-]+/iy;
|
|
4451
|
+
|
|
3370
4452
|
// Elements whose whitespace-only text children are never rendered.
|
|
3371
4453
|
const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
|
|
3372
4454
|
|
|
@@ -3386,6 +4468,38 @@ function stripTableWhitespace(el) {
|
|
|
3386
4468
|
}
|
|
3387
4469
|
}
|
|
3388
4470
|
|
|
4471
|
+
/**
|
|
4472
|
+
* Rename every `is` attribute to `_is`, rebuilding the element to do it.
|
|
4473
|
+
*
|
|
4474
|
+
* A component written as a dashed tag is neutralized in the shell by renaming the TAG
|
|
4475
|
+
* (`<my-tag>` becomes `<my-tag-SOLARITE-PLACEHOLDER>`), so the browser never recognizes the
|
|
4476
|
+
* placeholder and never upgrades it. A customized built-in cannot be neutralized that way,
|
|
4477
|
+
* because its tag has to stay real: a `<tr is="my-row">` that is not a `<tr>` is thrown out
|
|
4478
|
+
* by the parser's table rules. So its ATTRIBUTE is renamed instead.
|
|
4479
|
+
*
|
|
4480
|
+
* Renaming the attribute in place is not enough. `is` is also recorded in an internal slot on
|
|
4481
|
+
* the element, which removeAttribute() cannot clear and cloneNode() copies, so a placeholder
|
|
4482
|
+
* that was parsed with `is` stays a customized built-in as far as the browser is concerned.
|
|
4483
|
+
* Every clone of it is upgraded the moment it enters a document with a browsing context —
|
|
4484
|
+
* running the component's constructor on the placeholder, before PathToComponent has
|
|
4485
|
+
* instantiated the real element or evaluated the attribute expressions meant for it. A
|
|
4486
|
+
* constructor that renders then renders the placeholder, whose children are the ones the user
|
|
4487
|
+
* declared, and those get handed to the real instance as if they were slot content.
|
|
4488
|
+
*
|
|
4489
|
+
* Building a fresh element and moving everything across is the only way to drop that slot.
|
|
4490
|
+
* It happens once per unique template, because Shells are cached, and never per render.
|
|
4491
|
+
*
|
|
4492
|
+
* @param docFrag {DocumentFragment} */
|
|
4493
|
+
function renameIsAttribs(docFrag) {
|
|
4494
|
+
for (let el of docFrag.querySelectorAll('[is]')) {
|
|
4495
|
+
let clean = el.ownerDocument.createElement(el.tagName);
|
|
4496
|
+
for (let attrib of el.attributes)
|
|
4497
|
+
clean.setAttribute(attrib.name === 'is' ? '_is' : attrib.name, attrib.value);
|
|
4498
|
+
clean.append(...el.childNodes);
|
|
4499
|
+
el.replaceWith(clean);
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
|
|
3389
4503
|
// One-entry memo for Shell.get().
|
|
3390
4504
|
let lastHtmlStrings = null, lastSvgMode = false, lastShell = null;
|
|
3391
4505
|
|
|
@@ -3395,6 +4509,50 @@ const attribPlaceholder = 0xe000; // https://en.wikipedia.org/wiki/Private_Use_A
|
|
|
3395
4509
|
|
|
3396
4510
|
/** @typedef {boolean|string|number|function|Object|Array|Date|Node|Template} Expr */
|
|
3397
4511
|
|
|
4512
|
+
/** Stand-in Shell for text NodeGroups, which are never parsed from html. Its default field
|
|
4513
|
+
* values (no components, no live properties, no single-expression paths) are exactly what the
|
|
4514
|
+
* per-row code must see for a bare Text node, so ng.shell is never null. */
|
|
4515
|
+
const textShell = new Shell();
|
|
4516
|
+
|
|
4517
|
+
// The Shell whose delegated dispatchers a root last registered, kept on the RootNodeGroup so
|
|
4518
|
+
// that a run of rows checks one field instead of asking at every bound node. A Symbol rather
|
|
4519
|
+
// than a declared field, since only root NodeGroups ever carry it and a declared field would
|
|
4520
|
+
// cost a slot on every row. The delegation mode isn't part of it: it comes from the root's
|
|
4521
|
+
// render options, which are fixed when the root is created.
|
|
4522
|
+
const lastStampedShellKey = Symbol('solariteStampedShell');
|
|
4523
|
+
|
|
4524
|
+
/**
|
|
4525
|
+
* Run a Shell's precomputed resolve program (see Shell.buildResolveProgram) into the shell's
|
|
4526
|
+
* shared slots array, which the caller has already seeded with its starting node.
|
|
4527
|
+
* Each node is reached with firstChild/nextSibling pointer walks instead of childNodes[index];
|
|
4528
|
+
* the live NodeList indexing is markedly slower, and the indices are small (markers are
|
|
4529
|
+
* elements, often the first child after whitespace stripping). A negative step count means the
|
|
4530
|
+
* program reaches this node by walking forward from an earlier sibling's slot instead of from
|
|
4531
|
+
* its parent.
|
|
4532
|
+
* @param slots {Node[]} The shell's shared scratch array; slot 0 is the fragment.
|
|
4533
|
+
* @param ops {int[]} Flat [parentSlot, childIndex] pairs in dependency order.
|
|
4534
|
+
* @param i {int} Index of the first op pair to run; earlier pairs are pre-seeded by the caller.
|
|
4535
|
+
* @param s {int} Slot that pair fills.
|
|
4536
|
+
* @return {Node[]} slots, so callers can resolve and use it in one expression. */
|
|
4537
|
+
function runResolveOps(slots, ops, i, s) {
|
|
4538
|
+
for (; i<ops.length; i+=2, s++) {
|
|
4539
|
+
let k = ops[i+1], node;
|
|
4540
|
+
if (k < 0) {
|
|
4541
|
+
node = slots[ops[i]];
|
|
4542
|
+
do
|
|
4543
|
+
node = node.nextSibling;
|
|
4544
|
+
while (++k < 0);
|
|
4545
|
+
}
|
|
4546
|
+
else {
|
|
4547
|
+
node = slots[ops[i]].firstChild;
|
|
4548
|
+
for (; k>0; k--)
|
|
4549
|
+
node = node.nextSibling;
|
|
4550
|
+
}
|
|
4551
|
+
slots[s] = node;
|
|
4552
|
+
}
|
|
4553
|
+
return slots;
|
|
4554
|
+
}
|
|
4555
|
+
|
|
3398
4556
|
/**
|
|
3399
4557
|
* A group of Nodes instantiated from a Shell, with Expr's filled in.
|
|
3400
4558
|
*
|
|
@@ -3428,11 +4586,11 @@ class NodeGroup {
|
|
|
3428
4586
|
* matched by PathToNodes.applyKeyed(). Undefined for unkeyed NodeGroups. */
|
|
3429
4587
|
key;
|
|
3430
4588
|
|
|
3431
|
-
/** @type {
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
4589
|
+
/** @type {Shell} The Shell this NodeGroup was cloned from, so the per-row code can read
|
|
4590
|
+
* hasComponentPaths/hasLivePropPaths/pathsSingleExpr and the stamp program off it instead
|
|
4591
|
+
* of copying them onto every instance and re-looking the Shell up on every apply.
|
|
4592
|
+
* Text NodeGroups get the shared empty textShell, which reports false for all of them. */
|
|
4593
|
+
shell;
|
|
3436
4594
|
|
|
3437
4595
|
/** @type {boolean} True until applyExprs() finishes the first time.
|
|
3438
4596
|
* While true, ancestor node caches can't reference this NodeGroup's nodes, so they don't need invalidation. */
|
|
@@ -3443,6 +4601,11 @@ class NodeGroup {
|
|
|
3443
4601
|
* @type {Node[]} Cached result of getNodes() used only for improving performance.*/
|
|
3444
4602
|
nodesCache;
|
|
3445
4603
|
|
|
4604
|
+
/** @type {?Node[]} Slot nodes resolved by the first rewriteStamp(); a stamped group's
|
|
4605
|
+
* element structure never changes while it stays stampable, so they're reused on every
|
|
4606
|
+
* later rewrite. Declared here so every NodeGroup keeps one monomorphic hidden class. */
|
|
4607
|
+
stampSlotsCache = null;
|
|
4608
|
+
|
|
3446
4609
|
/**
|
|
3447
4610
|
* A map between <style> Elements and their text content.
|
|
3448
4611
|
* This lets NodeGroup.updateStyles() see when the style text has changed.
|
|
@@ -3464,7 +4627,7 @@ class NodeGroup {
|
|
|
3464
4627
|
this.rootNg = parentPath?.parentNg?.rootNg || this;
|
|
3465
4628
|
this.parentPath = parentPath;
|
|
3466
4629
|
|
|
3467
|
-
/*#
|
|
4630
|
+
/*#IFDEBUG*/assert(this.rootNg);/*#ENDIF*/
|
|
3468
4631
|
this.template = template;
|
|
3469
4632
|
|
|
3470
4633
|
// JSX templates carry their list key on the Template (tagged templates instead set it via
|
|
@@ -3475,24 +4638,22 @@ class NodeGroup {
|
|
|
3475
4638
|
// If it's just a text node, skip a bunch of unnecessary steps.
|
|
3476
4639
|
// el can be an existing Text node to adopt, from PathToNodes' bare-text fast path.
|
|
3477
4640
|
if (template.isText) {
|
|
4641
|
+
this.shell = textShell;
|
|
3478
4642
|
this.closeKey = template.getCloseKey();
|
|
3479
4643
|
this.startNode = this.endNode = el || Globals$1.doc.createTextNode(template.html[0]);
|
|
3480
4644
|
}
|
|
3481
4645
|
|
|
3482
4646
|
else {
|
|
3483
4647
|
// Get a cached version of the parsed and instantiated html, and Paths:
|
|
3484
|
-
const shell = Shell.get(template.html, template.svgMode);
|
|
4648
|
+
const shell = this.shell = Shell.get(template.html, template.svgMode);
|
|
3485
4649
|
|
|
3486
4650
|
// The shell caches the close key so each new template doesn't repeat the WeakMap lookup.
|
|
3487
4651
|
this.closeKey = shell.closeKey ??= template.getCloseKey();
|
|
3488
4652
|
|
|
3489
|
-
this.hasComponentPaths = shell.hasComponentPaths;
|
|
3490
|
-
this.pathsSingleExpr = shell.pathsSingleExpr;
|
|
3491
|
-
|
|
3492
4653
|
// A lone root element is cloned directly, skipping a throwaway fragment wrapper.
|
|
3493
4654
|
// Only for child NodeGroups; RootNodeGroup's grafting expects a fragment.
|
|
3494
4655
|
if (shell.singleRoot && parentPath !== null) {
|
|
3495
|
-
const clone = shell.
|
|
4656
|
+
const clone = shell.docFrag.firstChild.cloneNode(true);
|
|
3496
4657
|
this.startNode = this.endNode = clone;
|
|
3497
4658
|
|
|
3498
4659
|
// Stampable shells skip path creation entirely; the first applyExprs() routes
|
|
@@ -3501,7 +4662,7 @@ class NodeGroup {
|
|
|
3501
4662
|
this.setPathsFromFragment(clone, shell, 0, true);
|
|
3502
4663
|
}
|
|
3503
4664
|
else {
|
|
3504
|
-
const shellFragment = shell.
|
|
4665
|
+
const shellFragment = shell.docFrag.cloneNode(true);
|
|
3505
4666
|
|
|
3506
4667
|
if (shellFragment.nodeType === 11) { // DocumentFragment
|
|
3507
4668
|
this.startNode = shellFragment.firstChild;
|
|
@@ -3513,7 +4674,7 @@ class NodeGroup {
|
|
|
3513
4674
|
}
|
|
3514
4675
|
}
|
|
3515
4676
|
|
|
3516
|
-
//#
|
|
4677
|
+
//#IFDEBUG
|
|
3517
4678
|
this.verify();
|
|
3518
4679
|
//#ENDIF
|
|
3519
4680
|
}
|
|
@@ -3544,10 +4705,14 @@ class NodeGroup {
|
|
|
3544
4705
|
* Dispatches expression handling to other functions depending on the path type.
|
|
3545
4706
|
* @param exprs {(*|*[]|function|Template)[]}
|
|
3546
4707
|
* @param includeNonComponents {boolean} False to only apply component paths,
|
|
3547
|
-
* used when the non-component exprs are known to be unchanged.
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
4708
|
+
* used when the non-component exprs are known to be unchanged.
|
|
4709
|
+
* @param lastExprs {?Expr[]} The expressions applied last time, when the caller has them.
|
|
4710
|
+
* Paths that would provably do nothing with an unchanged expression are then skipped —
|
|
4711
|
+
* see Path.skipIfSame. A root template's event bindings are the usual beneficiaries:
|
|
4712
|
+
* they are the same handlers on every render, and re-binding them costs a call apiece. */
|
|
4713
|
+
applyExprs(exprs, includeNonComponents=true, lastExprs=null) {
|
|
4714
|
+
|
|
4715
|
+
/*#IFDEBUG*/
|
|
3551
4716
|
this.verify();
|
|
3552
4717
|
/*#ENDIF*/
|
|
3553
4718
|
|
|
@@ -3555,14 +4720,18 @@ class NodeGroup {
|
|
|
3555
4720
|
|
|
3556
4721
|
// Fast path: every path consumes exactly one expression and none are components,
|
|
3557
4722
|
// so skip the bookkeeping that maps expressions to paths.
|
|
3558
|
-
if (this.pathsSingleExpr) {
|
|
4723
|
+
if (this.shell.pathsSingleExpr) {
|
|
3559
4724
|
if (includeNonComponents) {
|
|
3560
4725
|
if (paths === null) { // Created from a stampable shell; no paths yet.
|
|
3561
4726
|
this.applyStamp(exprs);
|
|
3562
4727
|
return;
|
|
3563
4728
|
}
|
|
3564
|
-
for (let i = paths.length - 1; i >= 0; i--)
|
|
3565
|
-
paths[i]
|
|
4729
|
+
for (let i = paths.length - 1; i >= 0; i--) {
|
|
4730
|
+
let path = paths[i];
|
|
4731
|
+
if (lastExprs !== null && path.skipIfSame && lastExprs[i] === exprs[i])
|
|
4732
|
+
continue;
|
|
4733
|
+
path.applySingle(exprs[i]);
|
|
4734
|
+
}
|
|
3566
4735
|
|
|
3567
4736
|
if (this.styles)
|
|
3568
4737
|
this.updateStyles();
|
|
@@ -3589,7 +4758,7 @@ class NodeGroup {
|
|
|
3589
4758
|
let exprIndex = exprs.length; // Update exprs at paths.
|
|
3590
4759
|
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.
|
|
3591
4760
|
for (let i = paths.length - 1, path; path = paths[i]; i--) {
|
|
3592
|
-
if (i===0 && path instanceof PathToComponent && path.nodeMarker === this.
|
|
4761
|
+
if (i===0 && path instanceof PathToComponent && path.nodeMarker === this.getRootEl())
|
|
3593
4762
|
continue;
|
|
3594
4763
|
|
|
3595
4764
|
// Get the expressions associated with this path.
|
|
@@ -3601,15 +4770,15 @@ class NodeGroup {
|
|
|
3601
4770
|
// They use expressions from the paths that provide their attributes.
|
|
3602
4771
|
if (path instanceof PathToComponent) {
|
|
3603
4772
|
let attribExprs = pathExprs.slice(i+1, i+1 + path.attribPaths.length); // +1 b/c we move forward from the component path.
|
|
3604
|
-
path.
|
|
4773
|
+
path.applyAll(attribExprs);
|
|
3605
4774
|
}
|
|
3606
4775
|
else if (includeNonComponents)
|
|
3607
|
-
path.
|
|
4776
|
+
path.applyAll(pathExprs[i]);
|
|
3608
4777
|
}
|
|
3609
4778
|
|
|
3610
4779
|
// If there's leftover expressions, there's probably an issue with the Shell that created this NodeGroup,
|
|
3611
4780
|
// and the number of paths not matching.
|
|
3612
|
-
/*#
|
|
4781
|
+
/*#IFDEBUG*/
|
|
3613
4782
|
assert(exprIndex === 0);
|
|
3614
4783
|
/*#ENDIF*/
|
|
3615
4784
|
|
|
@@ -3625,7 +4794,7 @@ class NodeGroup {
|
|
|
3625
4794
|
}
|
|
3626
4795
|
this.firstApply = false;
|
|
3627
4796
|
|
|
3628
|
-
/*#
|
|
4797
|
+
/*#IFDEBUG*/
|
|
3629
4798
|
this.verify();
|
|
3630
4799
|
/*#ENDIF*/
|
|
3631
4800
|
}
|
|
@@ -3637,8 +4806,7 @@ class NodeGroup {
|
|
|
3637
4806
|
* falls back to materializing real paths and applying normally.
|
|
3638
4807
|
* @param exprs {Expr[]} */
|
|
3639
4808
|
applyStamp(exprs) {
|
|
3640
|
-
let
|
|
3641
|
-
let shell = Shell.get(template.html, template.svgMode);
|
|
4809
|
+
let shell = this.shell;
|
|
3642
4810
|
|
|
3643
4811
|
// 1. Bail to real paths when any child-node expression isn't a primitive.
|
|
3644
4812
|
let nodesIdx = shell.nodesPathIdx;
|
|
@@ -3654,27 +4822,71 @@ class NodeGroup {
|
|
|
3654
4822
|
}
|
|
3655
4823
|
}
|
|
3656
4824
|
|
|
3657
|
-
// 2. Resolve target nodes, then
|
|
4825
|
+
// 2. Resolve target nodes, then run the shell's compiled stamp program: a flat
|
|
4826
|
+
// opcode per path replaces per-path applySingle() dispatch (see Shell.stampOp).
|
|
3658
4827
|
let slots = this.resolveStampSlots(shell);
|
|
3659
|
-
let
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
4828
|
+
let ops = shell.stampOp, slotIdx = shell.stampSlot, aux = shell.stampAux;
|
|
4829
|
+
let stampers = shell.stampPaths;
|
|
4830
|
+
let rootNg = this.rootNg;
|
|
4831
|
+
let root = rootNg.rootEl;
|
|
4832
|
+
// Any value other than false or an array of event names means delegate everything.
|
|
4833
|
+
let opt = rootNg.renderOptions?.eventDelegation;
|
|
4834
|
+
let delegateAll = opt !== false && !Array.isArray(opt);
|
|
4835
|
+
|
|
4836
|
+
// Register this shell's delegated dispatchers once for a whole run of rows. They live on
|
|
4837
|
+
// the root and the document, not on the bound nodes, so asking per node — as the general
|
|
4838
|
+
// binding path has to — would be a call and a set lookup for every handler in the list.
|
|
4839
|
+
let names = shell.stampEventNames;
|
|
4840
|
+
if (names !== null && delegateAll && rootNg[lastStampedShellKey] !== shell) {
|
|
4841
|
+
for (let k=0; k<names.length; k++)
|
|
4842
|
+
ensureDelegatedDispatcher(root, names[k]);
|
|
4843
|
+
rootNg[lastStampedShellKey] = shell;
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4846
|
+
let firstApply = this.firstApply;
|
|
4847
|
+
for (let i = ops.length - 1; i >= 0; i--) {
|
|
4848
|
+
let v = exprs[i];
|
|
4849
|
+
let o = ops[i];
|
|
4850
|
+
|
|
4851
|
+
// Whole-parent child text: the marker is the (freshly cloned, empty) only-child
|
|
4852
|
+
// slot. Child exprs are primitive here (step 1 bailed otherwise).
|
|
4853
|
+
if (o === 2) {
|
|
3669
4854
|
if (typeof v === 'number')
|
|
3670
4855
|
v += '';
|
|
3671
|
-
|
|
3672
|
-
|
|
4856
|
+
slots[slotIdx[i]].textContent = v;
|
|
4857
|
+
}
|
|
4858
|
+
|
|
4859
|
+
// Delegatable event with a valid handler shape: write the node expandos
|
|
4860
|
+
// directly, mirroring bindEvent()'s delegated branch. An event-name-array
|
|
4861
|
+
// delegation option or an invalid value falls through to the generic stamper.
|
|
4862
|
+
else if (o === 3 && delegateAll
|
|
4863
|
+
&& (typeof v === 'function' || (Array.isArray(v) && typeof v[0] === 'function'))) {
|
|
4864
|
+
let sp = aux[i];
|
|
4865
|
+
let node = slots[slotIdx[i]];
|
|
4866
|
+
node[sp.delegatedKey] = v;
|
|
4867
|
+
node[delegatedRootKey] = root;
|
|
4868
|
+
}
|
|
4869
|
+
|
|
4870
|
+
// A plain attribute on a freshly cloned row: the shell left it off, so an empty
|
|
4871
|
+
// value means there is simply nothing to write, and any other string can go
|
|
4872
|
+
// straight in without reading the attribute back first.
|
|
4873
|
+
else if (o === 4 && firstApply && typeof v === 'string') {
|
|
4874
|
+
if (v !== '')
|
|
4875
|
+
slots[slotIdx[i]].setAttribute(aux[i], v);
|
|
3673
4876
|
}
|
|
3674
4877
|
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
4878
|
+
// The list key never touches the DOM.
|
|
4879
|
+
else if (o === 1)
|
|
4880
|
+
this.key = v;
|
|
4881
|
+
|
|
4882
|
+
// Everything else (attributes, disabled delegation, odd values) goes through
|
|
4883
|
+
// the shared stamper's full applySingle() semantics.
|
|
4884
|
+
else {
|
|
4885
|
+
let stamper = stampers[i];
|
|
4886
|
+
stamper.nodeMarker = slots[slotIdx[i]];
|
|
4887
|
+
stamper.parentNg = this;
|
|
4888
|
+
stamper.applySingle(v);
|
|
4889
|
+
}
|
|
3678
4890
|
}
|
|
3679
4891
|
|
|
3680
4892
|
this.nodesCache = null;
|
|
@@ -3688,7 +4900,7 @@ class NodeGroup {
|
|
|
3688
4900
|
* @return {boolean} False when a child-node expression isn't primitive; the caller
|
|
3689
4901
|
* must then materialize paths and apply normally. */
|
|
3690
4902
|
rewriteStamp(template) {
|
|
3691
|
-
let shell =
|
|
4903
|
+
let shell = this.shell;
|
|
3692
4904
|
let newExprs = template.exprs;
|
|
3693
4905
|
let nodesIdx = shell.nodesPathIdx;
|
|
3694
4906
|
for (let i=0; i<nodesIdx.length; i++) {
|
|
@@ -3698,19 +4910,29 @@ class NodeGroup {
|
|
|
3698
4910
|
}
|
|
3699
4911
|
|
|
3700
4912
|
let oldExprs = this.template.exprs;
|
|
3701
|
-
let
|
|
3702
|
-
let slots =
|
|
3703
|
-
for (let i =
|
|
3704
|
-
|
|
4913
|
+
let stampers = shell.stampPaths, slotIdx = shell.stampSlot, flags = shell.stampFlags;
|
|
4914
|
+
let slots = this.stampSlotsCache; // Nodes are resolved only if something actually changed, then cached.
|
|
4915
|
+
for (let i = stampers.length - 1; i >= 0; i--) {
|
|
4916
|
+
// Live HTML properties (checked etc., boolean-valued) are exempt from the
|
|
4917
|
+
// unchanged-value skip: a user's click flips the DOM property underneath the cached
|
|
4918
|
+
// expression, and applySingle() compares against the live node before writing.
|
|
4919
|
+
// The identity test is inline because most expressions are unchanged, and reaching
|
|
4920
|
+
// exprSame() only to be told so costs more than the comparison itself.
|
|
4921
|
+
let oldExpr = oldExprs[i], newExpr = newExprs[i];
|
|
4922
|
+
let flag = flags[i];
|
|
4923
|
+
if ((oldExpr !== newExpr && !exprSame(oldExpr, newExpr))
|
|
4924
|
+
|| ((flag & 1) && typeof newExpr === 'boolean')) {
|
|
4925
|
+
// .slice() is required: resolveStampSlots returns the Shell's SHARED scratch
|
|
4926
|
+
// array, which the next row's resolve would overwrite.
|
|
3705
4927
|
if (slots === null)
|
|
3706
|
-
slots = this.resolveStampSlots(shell);
|
|
4928
|
+
slots = this.stampSlotsCache = this.resolveStampSlots(shell).slice();
|
|
3707
4929
|
let stamper = stampers[i];
|
|
3708
|
-
let marker = slots[
|
|
4930
|
+
let marker = slots[slotIdx[i]]; // The flat slot array, so the Path isn't loaded.
|
|
3709
4931
|
|
|
3710
4932
|
// Fast path for a wholeParent text path whose child already exists (the common
|
|
3711
4933
|
// rewrite case): set its value directly, skipping applySingle's branching and
|
|
3712
4934
|
// textNode bookkeeping. exprSame above already proved it changed.
|
|
3713
|
-
if (
|
|
4935
|
+
if (flag & 2) {
|
|
3714
4936
|
let v = newExprs[i], tn = marker.firstChild;
|
|
3715
4937
|
if (typeof v === 'number')
|
|
3716
4938
|
v += '';
|
|
@@ -3745,16 +4967,10 @@ class NodeGroup {
|
|
|
3745
4967
|
* @return {Node[]} The shell's shared scratch slots array. */
|
|
3746
4968
|
resolveStampSlots(shell) {
|
|
3747
4969
|
let slots = shell.resolveSlots;
|
|
4970
|
+
// A singleRoot shell's first op pair is always [0, 0], so slot 1 is the row's own root
|
|
4971
|
+
// element and the program can start at the second pair.
|
|
3748
4972
|
slots[1] = this.startNode;
|
|
3749
|
-
|
|
3750
|
-
// firstChild/nextSibling pointer walk; see setPathsFromFragment for why not childNodes[i].
|
|
3751
|
-
for (let i=2, s=2; i<ops.length; i+=2, s++) {
|
|
3752
|
-
let node = slots[ops[i]].firstChild;
|
|
3753
|
-
for (let k=ops[i+1]; k>0; k--)
|
|
3754
|
-
node = node.nextSibling;
|
|
3755
|
-
slots[s] = node;
|
|
3756
|
-
}
|
|
3757
|
-
return slots;
|
|
4973
|
+
return runResolveOps(slots, shell.resolveOps, 2, 2);
|
|
3758
4974
|
}
|
|
3759
4975
|
|
|
3760
4976
|
/**
|
|
@@ -3764,17 +4980,8 @@ class NodeGroup {
|
|
|
3764
4980
|
* @param shell {?Shell}
|
|
3765
4981
|
* @return {Path[]} */
|
|
3766
4982
|
materializePaths(shell=null) {
|
|
3767
|
-
shell ??=
|
|
3768
|
-
let
|
|
3769
|
-
let paths = shell.paths;
|
|
3770
|
-
let pathLength = paths.length;
|
|
3771
|
-
let result = this.paths = new Array(pathLength);
|
|
3772
|
-
for (let i=0; i<pathLength; i++) {
|
|
3773
|
-
let p = paths[i];
|
|
3774
|
-
let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
|
|
3775
|
-
path.parentNg = this;
|
|
3776
|
-
result[i] = path;
|
|
3777
|
-
}
|
|
4983
|
+
shell ??= this.shell;
|
|
4984
|
+
let result = this.clonePathsFromSlots(shell, this.resolveStampSlots(shell));
|
|
3778
4985
|
|
|
3779
4986
|
// A wholeParent child-node path that stamped a primitive left exactly one Text child.
|
|
3780
4987
|
for (let idx of shell.nodesPathIdx) {
|
|
@@ -3814,14 +5021,8 @@ class NodeGroup {
|
|
|
3814
5021
|
/**
|
|
3815
5022
|
* Get the root element of the NodeGroup's RootNodeGroup.
|
|
3816
5023
|
* @returns {HTMLElement|DocumentFragment} */
|
|
3817
|
-
|
|
3818
|
-
return this.rootNg.
|
|
3819
|
-
}
|
|
3820
|
-
|
|
3821
|
-
/**
|
|
3822
|
-
* @returns {RootNodeGroup} */
|
|
3823
|
-
getRootNodeGroup() {
|
|
3824
|
-
return this.rootNg;
|
|
5024
|
+
getRootEl() {
|
|
5025
|
+
return this.rootNg.rootEl;
|
|
3825
5026
|
}
|
|
3826
5027
|
|
|
3827
5028
|
/**
|
|
@@ -3832,9 +5033,6 @@ class NodeGroup {
|
|
|
3832
5033
|
* @param isRootClone {boolean} True when fragment is a direct clone of a singleRoot
|
|
3833
5034
|
* shell's root element: it fills slot 1 itself and the first op pair is skipped. */
|
|
3834
5035
|
setPathsFromFragment(fragment, shell, startingPathDepth=0, isRootClone=false) {
|
|
3835
|
-
let paths = shell.paths;
|
|
3836
|
-
let pathLength = paths.length; // For faster iteration
|
|
3837
|
-
let result = this.paths = new Array(pathLength);
|
|
3838
5036
|
|
|
3839
5037
|
// Fast path: run the shell's precomputed resolve program (see Shell.buildResolveProgram).
|
|
3840
5038
|
// Each Path.clone() would walk childNodes from the fragment root to its target node,
|
|
@@ -3846,37 +5044,45 @@ class NodeGroup {
|
|
|
3846
5044
|
// attribPaths behavior; pathOffset!==0 (root grafting) also uses the fallback.
|
|
3847
5045
|
let ops = shell.resolveOps;
|
|
3848
5046
|
if (ops && startingPathDepth === 0) {
|
|
3849
|
-
let slots
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
s = 2;
|
|
3855
|
-
}
|
|
3856
|
-
else
|
|
5047
|
+
let slots;
|
|
5048
|
+
if (isRootClone) // The root element is also this.startNode, so it seeds slot 1 itself.
|
|
5049
|
+
slots = this.resolveStampSlots(shell);
|
|
5050
|
+
else {
|
|
5051
|
+
slots = shell.resolveSlots;
|
|
3857
5052
|
slots[0] = fragment;
|
|
3858
|
-
|
|
3859
|
-
// childNodes[index]; the live NodeList indexing is markedly slower, and indices
|
|
3860
|
-
// are small (markers are elements, often the first child after whitespace stripping).
|
|
3861
|
-
for (; i<ops.length; i+=2, s++) {
|
|
3862
|
-
let node = slots[ops[i]].firstChild;
|
|
3863
|
-
for (let k=ops[i+1]; k>0; k--)
|
|
3864
|
-
node = node.nextSibling;
|
|
3865
|
-
slots[s] = node;
|
|
3866
|
-
}
|
|
3867
|
-
for (let i=0; i<pathLength; i++) {
|
|
3868
|
-
let p = paths[i];
|
|
3869
|
-
let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
|
|
3870
|
-
path.parentNg = this;
|
|
3871
|
-
result[i] = path;
|
|
5053
|
+
runResolveOps(slots, ops, 0, 1);
|
|
3872
5054
|
}
|
|
5055
|
+
this.clonePathsFromSlots(shell, slots);
|
|
3873
5056
|
}
|
|
3874
|
-
else
|
|
5057
|
+
else {
|
|
5058
|
+
let paths = shell.paths;
|
|
5059
|
+
let pathLength = paths.length; // For faster iteration
|
|
5060
|
+
let result = this.paths = new Array(pathLength);
|
|
3875
5061
|
for (let i=0; i<pathLength; i++) {
|
|
3876
5062
|
let path = paths[i].clone(fragment, startingPathDepth);
|
|
3877
5063
|
path.parentNg = this;
|
|
3878
5064
|
result[i] = path;
|
|
3879
5065
|
}
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
/**
|
|
5070
|
+
* Copy the shell's Paths onto this NodeGroup's own nodes, taking each path's marker and
|
|
5071
|
+
* before-node from the slots the resolve program just filled.
|
|
5072
|
+
* @param shell {Shell}
|
|
5073
|
+
* @param slots {Node[]} The shell's shared scratch slots, already resolved.
|
|
5074
|
+
* @return {Path[]} */
|
|
5075
|
+
clonePathsFromSlots(shell, slots) {
|
|
5076
|
+
let paths = shell.paths;
|
|
5077
|
+
let pathLength = paths.length;
|
|
5078
|
+
let result = this.paths = new Array(pathLength);
|
|
5079
|
+
for (let i=0; i<pathLength; i++) {
|
|
5080
|
+
let p = paths[i];
|
|
5081
|
+
let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
|
|
5082
|
+
path.parentNg = this;
|
|
5083
|
+
result[i] = path;
|
|
5084
|
+
}
|
|
5085
|
+
return result;
|
|
3880
5086
|
}
|
|
3881
5087
|
|
|
3882
5088
|
updateStyles() {
|
|
@@ -3884,7 +5090,7 @@ class NodeGroup {
|
|
|
3884
5090
|
for (let [style, oldText] of this.styles) {
|
|
3885
5091
|
let newText = style.textContent;
|
|
3886
5092
|
if (oldText !== newText)
|
|
3887
|
-
Util.bindStyles(style, this.
|
|
5093
|
+
Util.bindStyles(style, this.rootNg.rootEl);
|
|
3888
5094
|
}
|
|
3889
5095
|
}
|
|
3890
5096
|
|
|
@@ -3894,16 +5100,14 @@ class NodeGroup {
|
|
|
3894
5100
|
* @param pathOffset {int} */
|
|
3895
5101
|
activateEmbeds(root, shell, pathOffset=0) {
|
|
3896
5102
|
|
|
3897
|
-
let rootEl = this.rootNg.
|
|
5103
|
+
let rootEl = this.rootNg.rootEl;
|
|
3898
5104
|
if (rootEl) {
|
|
3899
|
-
let options = this.rootNg.
|
|
5105
|
+
let options = this.rootNg.renderOptions;
|
|
3900
5106
|
|
|
3901
5107
|
// ids
|
|
3902
5108
|
if (options?.ids !== false) {
|
|
3903
5109
|
for (let path of shell.ids) {
|
|
3904
|
-
|
|
3905
|
-
path = path.slice(0, -pathOffset);
|
|
3906
|
-
let el = Path.resolve(root, path);
|
|
5110
|
+
let el = Path.resolve(root, path, pathOffset);
|
|
3907
5111
|
Util.bindId(rootEl, el);
|
|
3908
5112
|
}
|
|
3909
5113
|
}
|
|
@@ -3913,11 +5117,8 @@ class NodeGroup {
|
|
|
3913
5117
|
if (shell.styles.length)
|
|
3914
5118
|
this.styles = new Map();
|
|
3915
5119
|
for (let path of shell.styles) {
|
|
3916
|
-
if (pathOffset)
|
|
3917
|
-
path = path.slice(0, -pathOffset);
|
|
3918
|
-
|
|
3919
5120
|
/** @type {HTMLStyleElement} */
|
|
3920
|
-
let style = Path.resolve(root, path);
|
|
5121
|
+
let style = Path.resolve(root, path, pathOffset);
|
|
3921
5122
|
if (rootEl.nodeType === 1) {
|
|
3922
5123
|
Util.bindStyles(style, rootEl);
|
|
3923
5124
|
this.styles.set(style, style.textContent);
|
|
@@ -3928,9 +5129,7 @@ class NodeGroup {
|
|
|
3928
5129
|
// scripts
|
|
3929
5130
|
if (options?.scripts !== false) {
|
|
3930
5131
|
for (let path of shell.scripts) {
|
|
3931
|
-
|
|
3932
|
-
path = path.slice(0, -pathOffset);
|
|
3933
|
-
let script = Path.resolve(root, path);
|
|
5132
|
+
let script = Path.resolve(root, path, pathOffset);
|
|
3934
5133
|
// Indirect eval runs in global scope (correct for a <script> tag) and, unlike a direct
|
|
3935
5134
|
// eval, doesn't force terser to keep every top-level name in the bundle unmangled.
|
|
3936
5135
|
(0, eval)(script.textContent);
|
|
@@ -3939,7 +5138,7 @@ class NodeGroup {
|
|
|
3939
5138
|
}
|
|
3940
5139
|
}
|
|
3941
5140
|
|
|
3942
|
-
//#
|
|
5141
|
+
//#IFDEBUG
|
|
3943
5142
|
getParentNode() {
|
|
3944
5143
|
return this.startNode?.parentNode
|
|
3945
5144
|
}
|
|
@@ -4009,8 +5208,8 @@ class NodeGroup {
|
|
|
4009
5208
|
* Has these properties not present on NodeGroup, assigned by instantiate():
|
|
4010
5209
|
* They're not declared as fields because subclass field initializers run after the
|
|
4011
5210
|
* super constructor and would overwrite the assigned values.
|
|
4012
|
-
* @property {HTMLElement}
|
|
4013
|
-
* @property {?object}
|
|
5211
|
+
* @property {HTMLElement} rootEl - Root node at the top of the hierarchy.
|
|
5212
|
+
* @property {?object} renderOptions - RenderOptions */
|
|
4014
5213
|
class RootNodeGroup extends NodeGroup {
|
|
4015
5214
|
|
|
4016
5215
|
/**
|
|
@@ -4019,37 +5218,54 @@ class RootNodeGroup extends NodeGroup {
|
|
|
4019
5218
|
* Called by the NodeGroup constructor. */
|
|
4020
5219
|
instantiate(shell, shellFragment, el, options) {
|
|
4021
5220
|
let startingPathDepth = 0;
|
|
4022
|
-
this.
|
|
5221
|
+
this.renderOptions = options;
|
|
4023
5222
|
if (shellFragment instanceof Text) {
|
|
4024
5223
|
if (!el)
|
|
4025
|
-
throw new Error('
|
|
5224
|
+
throw new Error('Text node needs an element.');
|
|
4026
5225
|
|
|
4027
|
-
this.
|
|
5226
|
+
this.rootEl = el;
|
|
4028
5227
|
if (shellFragment.nodeValue.length)
|
|
4029
|
-
this.
|
|
5228
|
+
this.rootEl.append(shellFragment);
|
|
4030
5229
|
}
|
|
4031
5230
|
|
|
4032
5231
|
else {
|
|
4033
5232
|
if (el) {
|
|
4034
|
-
this.
|
|
5233
|
+
this.rootEl = el;
|
|
5234
|
+
|
|
5235
|
+
// Save the children that belong in this component's <slot>, from one of two places:
|
|
5236
|
+
// 1. A hand-off parked by PathToComponent.applyAll() just before it constructed
|
|
5237
|
+
// us, when this component was declared inside another template. It carries
|
|
5238
|
+
// the Constructor it was meant for, so an unrelated component built in the
|
|
5239
|
+
// meantime -- a field initializer creating a menu, say -- leaves it alone.
|
|
5240
|
+
// 2. el.childNodes, when render() is called manually for the first time.
|
|
5241
|
+
// An addressed hand-off wins even when its node list is empty: a component
|
|
5242
|
+
// declared as <my-tag></my-tag> is asking for an empty slot, not for whatever
|
|
5243
|
+
// its own constructor happened to put in the element.
|
|
5244
|
+
//
|
|
5245
|
+
// The hand-off is deliberately NOT cleared on read. A component that builds
|
|
5246
|
+
// another instance of its OWN class while constructing cannot be told apart
|
|
5247
|
+
// from itself by any address, so both match; the inner one takes the nodes and
|
|
5248
|
+
// this outer one takes them straight back, which is the only thing that makes
|
|
5249
|
+
// that case work.
|
|
5250
|
+
let handOff = Globals$1.currentSlotChildren;
|
|
5251
|
+
let mySlotNodes = handOff?.Constructor === el.constructor
|
|
5252
|
+
? handOff.nodes
|
|
5253
|
+
: (el.childNodes.length ? [...el.childNodes] : null);
|
|
4035
5254
|
|
|
4036
|
-
// Save slot
|
|
4037
|
-
// 1. Globals.currentSlotChildren is set if this is called via PathToComponent.applyComponent() calls render()
|
|
4038
|
-
// 2. el.childNodes is set if render() is called manually for the first time.
|
|
4039
5255
|
let slotChildren;
|
|
4040
|
-
if (
|
|
5256
|
+
if (mySlotNodes) {
|
|
4041
5257
|
slotChildren = Globals$1.doc.createDocumentFragment();
|
|
4042
|
-
slotChildren.append(...
|
|
5258
|
+
slotChildren.append(...mySlotNodes);
|
|
4043
5259
|
}
|
|
4044
5260
|
|
|
4045
5261
|
// If el should replace the root node of the fragment.
|
|
4046
|
-
if (isReplaceEl(shellFragment, this.
|
|
4047
|
-
this.
|
|
5262
|
+
if (isReplaceEl(shellFragment, this.rootEl.tagName)) {
|
|
5263
|
+
this.rootEl.append(...shellFragment.children[0].childNodes);
|
|
4048
5264
|
|
|
4049
5265
|
// Copy attributes
|
|
4050
5266
|
for (let attrib of shellFragment.children[0].attributes)
|
|
4051
|
-
if (!this.
|
|
4052
|
-
this.
|
|
5267
|
+
if (!this.rootEl.hasAttribute(attrib.name))
|
|
5268
|
+
this.rootEl.setAttribute(attrib.name, attrib.value);
|
|
4053
5269
|
|
|
4054
5270
|
// Go one level deeper into all of shell's paths.
|
|
4055
5271
|
startingPathDepth = 1;
|
|
@@ -4058,7 +5274,7 @@ class RootNodeGroup extends NodeGroup {
|
|
|
4058
5274
|
else {
|
|
4059
5275
|
let isEmpty = shellFragment.childNodes.length === 1 && shellFragment.childNodes[0].nodeType === 3 && shellFragment.childNodes[0].textContent === '';
|
|
4060
5276
|
if (!isEmpty)
|
|
4061
|
-
this.
|
|
5277
|
+
this.rootEl.append(...shellFragment.childNodes);
|
|
4062
5278
|
}
|
|
4063
5279
|
|
|
4064
5280
|
|
|
@@ -4084,34 +5300,26 @@ class RootNodeGroup extends NodeGroup {
|
|
|
4084
5300
|
|
|
4085
5301
|
// Instantiate as a standalone element.
|
|
4086
5302
|
else {
|
|
4087
|
-
|
|
4088
|
-
|
|
5303
|
+
// Trimming the whitespace and comment nodes off both ends leaves a list of exactly
|
|
5304
|
+
// one node only when the fragment has exactly one node worth keeping, which is the
|
|
5305
|
+
// question being asked here.
|
|
5306
|
+
let relevantNodes = Util.trimEmptyNodes(shellFragment.childNodes);
|
|
5307
|
+
let onlyChild = relevantNodes.length === 1 ? relevantNodes[0] : null;
|
|
5308
|
+
this.rootEl = onlyChild || shellFragment; // We return the whole fragment when calling h() with a collection of nodes.
|
|
4089
5309
|
if (onlyChild)
|
|
4090
5310
|
startingPathDepth = 1;
|
|
4091
5311
|
}
|
|
4092
5312
|
|
|
4093
|
-
this.setPathsFromFragment(this.
|
|
4094
|
-
this.activateEmbeds(this.
|
|
5313
|
+
this.setPathsFromFragment(this.rootEl, shell, startingPathDepth);
|
|
5314
|
+
this.activateEmbeds(this.rootEl, shell, startingPathDepth);
|
|
4095
5315
|
}
|
|
4096
|
-
this.startNode = this.endNode = this.
|
|
5316
|
+
this.startNode = this.endNode = this.rootEl;
|
|
4097
5317
|
|
|
4098
|
-
Globals$1.rootNodeGroups.set(this.
|
|
5318
|
+
Globals$1.rootNodeGroups.set(this.rootEl, this);
|
|
4099
5319
|
}
|
|
4100
5320
|
}
|
|
4101
5321
|
|
|
4102
5322
|
|
|
4103
|
-
function getSingleEl(fragment) {
|
|
4104
|
-
let nonempty = [];
|
|
4105
|
-
for (let n of fragment.childNodes) {
|
|
4106
|
-
if (n.nodeType === 1 || n.nodeType === 3 && n.textContent.trim().length) {
|
|
4107
|
-
if (nonempty.length)
|
|
4108
|
-
return null;
|
|
4109
|
-
nonempty.push(n);
|
|
4110
|
-
}
|
|
4111
|
-
}
|
|
4112
|
-
return nonempty[0];
|
|
4113
|
-
}
|
|
4114
|
-
|
|
4115
5323
|
/**
|
|
4116
5324
|
* Does the fragment have one child that's an element matching the tagname of el?
|
|
4117
5325
|
* @param fragment {DocumentFragment}
|
|
@@ -4174,7 +5382,7 @@ class Template {
|
|
|
4174
5382
|
|
|
4175
5383
|
//this.trace = new Error().stack.split(/\n/g)
|
|
4176
5384
|
|
|
4177
|
-
//#
|
|
5385
|
+
//#IFDEBUG
|
|
4178
5386
|
assert(Array.isArray(htmlStrings));
|
|
4179
5387
|
assert(Array.isArray(exprs));
|
|
4180
5388
|
|
|
@@ -4199,8 +5407,12 @@ class Template {
|
|
|
4199
5407
|
if (!ng) {
|
|
4200
5408
|
ng = new RootNodeGroup(this, null, el, options);
|
|
4201
5409
|
if (!el) // null if it's a standalone elment.
|
|
4202
|
-
el = ng.
|
|
4203
|
-
|
|
5410
|
+
el = ng.getRootEl();
|
|
5411
|
+
|
|
5412
|
+
// RootNodeGroup.instantiate() ends by registering itself under its own rootEl, which
|
|
5413
|
+
// is the element we were given, or -- when we were given none -- the very element
|
|
5414
|
+
// getRootEl() just handed back. Registering it a second time here stored the same
|
|
5415
|
+
// group under the same key.
|
|
4204
5416
|
}
|
|
4205
5417
|
|
|
4206
5418
|
// Make sure the expresion count matches match the Path "hole" count.
|
|
@@ -4215,8 +5427,13 @@ class Template {
|
|
|
4215
5427
|
// If we didn't just create it, we need to render it.
|
|
4216
5428
|
if (this.html?.length === 1 && !this.html[0]) // An empty string.
|
|
4217
5429
|
el.innerHTML = ''; // Fast path for empty component.
|
|
4218
|
-
else
|
|
4219
|
-
|
|
5430
|
+
else {
|
|
5431
|
+
// A component renders the same template every time, so hand over the expressions it
|
|
5432
|
+
// applied last time; paths that can prove an unchanged expression is a no-op skip.
|
|
5433
|
+
let last = ng.template;
|
|
5434
|
+
ng.applyExprs(this.exprs, true, last !== this && last.html === this.html ? last.exprs : null);
|
|
5435
|
+
ng.template = this;
|
|
5436
|
+
}
|
|
4220
5437
|
|
|
4221
5438
|
return el;
|
|
4222
5439
|
}
|
|
@@ -4246,9 +5463,13 @@ class Template {
|
|
|
4246
5463
|
function templatesSame(a, b) {
|
|
4247
5464
|
if (a.html === b.html && a.svgMode === b.svgMode) {
|
|
4248
5465
|
let ae = a.exprs, be = b.exprs;
|
|
4249
|
-
|
|
4250
|
-
|
|
5466
|
+
// Most expressions are identical between renders, so test that here rather than paying
|
|
5467
|
+
// a call into exprSame() to learn it.
|
|
5468
|
+
for (let i=0; i<ae.length; i++) {
|
|
5469
|
+
let x = ae[i], y = be[i];
|
|
5470
|
+
if (x !== y && !exprSame(x, y))
|
|
4251
5471
|
return false;
|
|
5472
|
+
}
|
|
4252
5473
|
return true;
|
|
4253
5474
|
}
|
|
4254
5475
|
|
|
@@ -4354,7 +5575,7 @@ function toEl(arg) {
|
|
|
4354
5575
|
let obj = arg;
|
|
4355
5576
|
|
|
4356
5577
|
if (obj.constructor.name !== 'Object')
|
|
4357
|
-
throw new Error(`
|
|
5578
|
+
throw new Error(`Solarite web component class ${obj.constructor?.name} must extend HTMLElement.`);
|
|
4358
5579
|
|
|
4359
5580
|
// Normal path
|
|
4360
5581
|
if (!Globals$1.objToEl.has(obj)) {
|
|
@@ -4442,7 +5663,11 @@ const renderTemplateKey = Symbol('solariteRender');
|
|
|
4442
5663
|
// Using `arguments` alongside rest params would force the engine to materialize both per call.
|
|
4443
5664
|
const noArg = Symbol();
|
|
4444
5665
|
|
|
4445
|
-
|
|
5666
|
+
// The /** @type {*} */ cast on the default keeps TypeScript from inferring the parameter as
|
|
5667
|
+
// `symbol` from noArg: TS can't parse the closure-style @param type above (function() without
|
|
5668
|
+
// a return type under noImplicitAny), falls back to the default's type, and then flags every
|
|
5669
|
+
// h(this) / h`` call in the codebase as an error. JetBrains reads the @param fine either way.
|
|
5670
|
+
function h(htmlStrings=/** @type {*} */(noArg), ...exprs) {
|
|
4446
5671
|
|
|
4447
5672
|
// 1. Tagged template: h`<div>...</div>`
|
|
4448
5673
|
if (Array.isArray(htmlStrings)) {
|
|
@@ -4495,11 +5720,14 @@ function h(htmlStrings=noArg, ...exprs) {
|
|
|
4495
5720
|
let parent = htmlStrings, options = exprs[0];
|
|
4496
5721
|
|
|
4497
5722
|
// The closure is cached on the element so repeated renders don't recreate it.
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
5723
|
+
// Options are cached with it: they only take effect when the element's
|
|
5724
|
+
// RootNodeGroup is first created, so a later render passing different ones is
|
|
5725
|
+
// ignored either way, and caching regardless of them saves an allocation on every
|
|
5726
|
+
// render of a component that passes an options object — which is how render() is
|
|
5727
|
+
// usually written.
|
|
5728
|
+
let cached = parent[renderTemplateKey];
|
|
5729
|
+
if (cached)
|
|
5730
|
+
return cached;
|
|
4503
5731
|
|
|
4504
5732
|
// Return a tagged template function that applies the tagged template to parent.
|
|
4505
5733
|
let renderTemplate = (htmlStrings, ...exprs) => {
|
|
@@ -4511,8 +5739,7 @@ function h(htmlStrings=noArg, ...exprs) {
|
|
|
4511
5739
|
let template = new Template(htmlStrings, exprs);
|
|
4512
5740
|
return template.render(parent, options);
|
|
4513
5741
|
};
|
|
4514
|
-
|
|
4515
|
-
parent[renderTemplateKey] = renderTemplate;
|
|
5742
|
+
parent[renderTemplateKey] = renderTemplate;
|
|
4516
5743
|
return renderTemplate;
|
|
4517
5744
|
}
|
|
4518
5745
|
}
|
|
@@ -4529,11 +5756,11 @@ function h(htmlStrings=noArg, ...exprs) {
|
|
|
4529
5756
|
// Intercepts the main h(this)`...` function call inside render().
|
|
4530
5757
|
// TODO: This path doesn't handle embeds like data-id="..."
|
|
4531
5758
|
else if (typeof htmlStrings === 'object' && Globals$1.objToEl.has(htmlStrings)) {
|
|
5759
|
+
// The only thing that ever puts an object into objToEl is toEl(), and it rejects anything
|
|
5760
|
+
// that isn't a plain object before it does so, so an object that reaches here has already
|
|
5761
|
+
// been checked and re-checking it can never report anything.
|
|
4532
5762
|
let obj = htmlStrings;
|
|
4533
5763
|
|
|
4534
|
-
if (obj.constructor.name !== 'Object')
|
|
4535
|
-
throw new Error(`Solarate Web Component class ${obj.constructor?.name} must extend HTMLElement.`);
|
|
4536
|
-
|
|
4537
5764
|
// Jsx with h(this, <jsx>)
|
|
4538
5765
|
if (exprs[0] instanceof Template) {
|
|
4539
5766
|
let template = exprs[0];
|
|
@@ -4557,14 +5784,6 @@ function h(htmlStrings=noArg, ...exprs) {
|
|
|
4557
5784
|
throw new Error('h() does not support argument of type: ' + (htmlStrings ? typeof htmlStrings : htmlStrings))
|
|
4558
5785
|
}
|
|
4559
5786
|
|
|
4560
|
-
// h.map caches each item's Template keyed by the item's identity, so a re-render returns
|
|
4561
|
-
// the SAME Template instance for any item whose reference is unchanged. The reconciler's
|
|
4562
|
-
// `ng.template === item` fast path (PathToNodes.applyKeyed/applyDiff) then skips rebuilding
|
|
4563
|
-
// and comparing that row. A WeakMap is used instead of a symbol property so the idiomatic
|
|
4564
|
-
// immutable update `{...item, x}` yields a fresh object that ISN'T in the cache and re-renders;
|
|
4565
|
-
// a symbol property would be copied by spread and silently reuse the stale Template.
|
|
4566
|
-
const mapCache = new WeakMap();
|
|
4567
|
-
|
|
4568
5787
|
/**
|
|
4569
5788
|
* Render a list, reusing each item's DOM for as long as the item is the SAME object.
|
|
4570
5789
|
*
|
|
@@ -4581,37 +5800,73 @@ const mapCache = new WeakMap();
|
|
|
4581
5800
|
*
|
|
4582
5801
|
* ${h.map(this.rows, row => h`<tr key=${row.id}>${row.label}</tr>`)}
|
|
4583
5802
|
*
|
|
5803
|
+
* What comes back is a MappedList, not an array: it carries the items and the callback so
|
|
5804
|
+
* the reconciler can match a row to its item by identity and call the callback only for the
|
|
5805
|
+
* rows it can't match. Put it straight into a template expression, as above; nested inside
|
|
5806
|
+
* an array, or returned from a function, it expands to Templates just the same.
|
|
5807
|
+
*
|
|
4584
5808
|
* @param items {Array} The list to render.
|
|
4585
5809
|
* @param fn {function(item:*):Template} Builds an item's Template; called only for new items.
|
|
4586
|
-
* @return {
|
|
4587
|
-
h.map = (items, fn) =>
|
|
4588
|
-
let result = new Array(items.length);
|
|
4589
|
-
for (let i=0; i<items.length; i++) {
|
|
4590
|
-
let item = items[i];
|
|
4591
|
-
if (item !== null && typeof item === 'object') {
|
|
4592
|
-
let template = mapCache.get(item);
|
|
4593
|
-
if (template === undefined) {
|
|
4594
|
-
template = fn(item);
|
|
4595
|
-
mapCache.set(item, template);
|
|
4596
|
-
}
|
|
4597
|
-
result[i] = template;
|
|
4598
|
-
}
|
|
4599
|
-
else
|
|
4600
|
-
result[i] = fn(item);
|
|
4601
|
-
}
|
|
4602
|
-
return result;
|
|
4603
|
-
};
|
|
5810
|
+
* @return {MappedList} */
|
|
5811
|
+
h.map = (items, fn) => new MappedList(items, fn);
|
|
4604
5812
|
|
|
4605
5813
|
h.immutableMap = h.map;
|
|
4606
5814
|
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
5815
|
+
/**
|
|
5816
|
+
* Create a selection that updates only the rows it affects.
|
|
5817
|
+
*
|
|
5818
|
+
* A highlight that moves from one row of a thousand to another changes two attributes.
|
|
5819
|
+
* Expressing it as ordinary state means calling render() and letting the reconciler walk the
|
|
5820
|
+
* list to rediscover that. A selector writes those two attributes directly instead:
|
|
5821
|
+
*
|
|
5822
|
+
* class Table extends Solarite {
|
|
5823
|
+
* selected = h.selector();
|
|
5824
|
+
*
|
|
5825
|
+
* pick(row) {
|
|
5826
|
+
* this.selected.set(row.id); // no render() call
|
|
5827
|
+
* }
|
|
5828
|
+
*
|
|
5829
|
+
* render() {
|
|
5830
|
+
* h(this)`<tbody>${h.map(this.rows, row =>
|
|
5831
|
+
* h`<tr key=${row.id} class=${this.selected.when(row.id, 'danger')}
|
|
5832
|
+
* onclick=${[this.pick, row]}>${row.label}</tr>`)}</tbody>`;
|
|
5833
|
+
* }
|
|
5834
|
+
* }
|
|
5835
|
+
*
|
|
5836
|
+
* when() must be a whole attribute value, not part of one and not element content, since it
|
|
5837
|
+
* owns that attribute for as long as the row exists. An off value of '' leaves no attribute
|
|
5838
|
+
* behind at all. Selection state lives on the selector, so it survives re-renders, and
|
|
5839
|
+
* set() is safe to call whether or not the rows are currently rendered.
|
|
5840
|
+
*
|
|
5841
|
+
* Two rules follow from how set() finds a row, and both throw a clear error rather than
|
|
5842
|
+
* misbehaving quietly. **The rows must be keyed** — set() locates a row by looking its key
|
|
5843
|
+
* up in the list, so the row template needs a key=${...}. And **the attribute must sit on
|
|
5844
|
+
* the row's own root element**, the same one that carries the key, because that is the
|
|
5845
|
+
* element set() writes. Drawing a row costs nothing either way: when() hands back one of
|
|
5846
|
+
* two shared objects rather than allocating anything per row, so a selector is free to
|
|
5847
|
+
* render over a list of any size and only a change of selection does any work.
|
|
5848
|
+
*
|
|
5849
|
+
* @param key {*} The initially selected key, or null for none.
|
|
5850
|
+
* @return {Selector} */
|
|
5851
|
+
h.selector = (key = null) => new Selector(key);
|
|
5852
|
+
|
|
5853
|
+
/**
|
|
5854
|
+
* Convert an attribute string with the given converter: Number, Boolean, String, Date,
|
|
5855
|
+
* or any function taking the string and returning a value. Boolean is true for any string
|
|
5856
|
+
* except 'false' and '0', so a bare attribute like `<my-timer auto-start>` reads as true.
|
|
5857
|
+
* Date uses new Date(value). No converter returns the string unchanged. */
|
|
5858
|
+
function convertType(value, type) {
|
|
5859
|
+
if (type === Date)
|
|
5860
|
+
return new Date(value);
|
|
5861
|
+
if (type === Boolean)
|
|
5862
|
+
return !['false', '0'].includes(value);
|
|
5863
|
+
// Number and String need no cases of their own: they're plain functions, so the custom
|
|
5864
|
+
// branch below calls them correctly. Date and Boolean are the ones that can't fall through
|
|
5865
|
+
// (Date without `new` returns a string; Boolean('false') is true).
|
|
5866
|
+
if (type) // Number, String, or a custom string=>value function
|
|
5867
|
+
return type(value);
|
|
5868
|
+
return value;
|
|
5869
|
+
}
|
|
4615
5870
|
|
|
4616
5871
|
/**
|
|
4617
5872
|
* Read an element's html attributes onto fields that already exist on the element.
|
|
@@ -4647,16 +5902,8 @@ function assignAttributes(dest, types={}, ignore=[]) {
|
|
|
4647
5902
|
dest[name] = JSON.parse(value.slice(2, -1));
|
|
4648
5903
|
|
|
4649
5904
|
// 2. Cast the string with the converter named in `types`, if any.
|
|
4650
|
-
else if (type
|
|
4651
|
-
dest[name] =
|
|
4652
|
-
else if (type === Boolean)
|
|
4653
|
-
dest[name] = !['false', '0'].includes(value);
|
|
4654
|
-
else if (type === Number)
|
|
4655
|
-
dest[name] = Number(value);
|
|
4656
|
-
else if (type === String)
|
|
4657
|
-
dest[name] = String(value);
|
|
4658
|
-
else if (type) // custom string=>value function
|
|
4659
|
-
dest[name] = type(value);
|
|
5905
|
+
else if (type)
|
|
5906
|
+
dest[name] = convertType(value, type);
|
|
4660
5907
|
|
|
4661
5908
|
// 3. No converter named: assign the raw string. But an empty value over a function/object
|
|
4662
5909
|
// field is just the serialization residue of a template expression (functions render as
|
|
@@ -4706,42 +5953,46 @@ let HTMLElementAutoDefine = new Proxy(HTMLElement, {
|
|
|
4706
5953
|
class Solarite extends HTMLElementAutoDefine {
|
|
4707
5954
|
|
|
4708
5955
|
/**
|
|
4709
|
-
*
|
|
4710
|
-
|
|
5956
|
+
* Fill in and fix up the attribs object a component's constructor receives, so the component
|
|
5957
|
+
* can then copy those values onto its own fields, e.g. with ObjectUtil.assign(this, attribs).
|
|
5958
|
+
*
|
|
5959
|
+
* 1. If attribs is an empty object, fill it with the attributes on the DOM element.
|
|
5960
|
+
* This happens when the browser creates the element from plain html, because then nothing
|
|
5961
|
+
* calls the constructor with arguments. Attribute names convert from dash-case to
|
|
5962
|
+
* camelCase, and `${...}` values are parsed from JSON.
|
|
5963
|
+
* 2. If types is given, convert attribs values from strings to those types. Attribute values
|
|
5964
|
+
* written as literal text always arrive as strings, whether from plain html or from an h()
|
|
5965
|
+
* template. types maps a field name to Number, Boolean, String, Date, or any function
|
|
5966
|
+
* taking the string and returning a value. Boolean is true for every string except
|
|
5967
|
+
* 'false' and '0', so a bare attribute like `<select-box-3 editable>` becomes true.
|
|
5968
|
+
* Values that are already not strings, like a `${true}` template expression, are left alone.
|
|
5969
|
+
*
|
|
5970
|
+
* This runs before the subclass initializes its fields and renders, so converted values are
|
|
5971
|
+
* right the first time, even for fields that change what render() builds. This constructor
|
|
5972
|
+
* can't copy attribs onto fields itself, because subclass field initializers run after it
|
|
5973
|
+
* finishes and would overwrite them; that's why the subclass does the final assign.
|
|
5974
|
+
* @param attribs {?Record<string, any>}
|
|
5975
|
+
* @param types {?Record<string, Function>} */
|
|
5976
|
+
constructor(attribs=null, types=null) {
|
|
4711
5977
|
super();
|
|
4712
5978
|
|
|
4713
5979
|
if (attribs) {
|
|
4714
5980
|
if (typeof attribs !== 'object')
|
|
4715
|
-
throw new Error('First argument
|
|
5981
|
+
throw new Error('First argument must be an object.');
|
|
4716
5982
|
|
|
4717
5983
|
// 1. Populate attribs if it's an empty object.
|
|
4718
|
-
if (
|
|
5984
|
+
if (!Object.keys(attribs).length) {
|
|
4719
5985
|
let attribs2 = Solarite.getAttribs(this);
|
|
4720
5986
|
for (let name in attribs2) {
|
|
4721
5987
|
attribs[name] = attribs2[name];
|
|
4722
5988
|
}
|
|
4723
5989
|
}
|
|
4724
5990
|
|
|
4725
|
-
// 2.
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
// const descriptor = Object.getOwnPropertyDescriptor(this, name);
|
|
4730
|
-
// if (!descriptor || descriptor.writable || descriptor.set)
|
|
4731
|
-
// this[name] = attribs[name];
|
|
4732
|
-
// }
|
|
4733
|
-
//}
|
|
5991
|
+
// 2. Convert string values to the types the component declares.
|
|
5992
|
+
for (let name in types || {})
|
|
5993
|
+
if (typeof attribs[name] === 'string')
|
|
5994
|
+
attribs[name] = convertType(attribs[name], types[name]);
|
|
4734
5995
|
}
|
|
4735
|
-
|
|
4736
|
-
// 3. Wrap render function so it always provides the attribs argument.
|
|
4737
|
-
// Disabled because this gives us strings for attribute values when we call render manually.
|
|
4738
|
-
// Instead of values given from ${...} expressions.
|
|
4739
|
-
// let originalRender = this.render;
|
|
4740
|
-
// this.render = (attribs, changed=true) => {
|
|
4741
|
-
// if (!attribs) // If we have to look up the attribs, we don't know if they changed or not.
|
|
4742
|
-
// attribs = Solarite.getAttribs(this);
|
|
4743
|
-
// originalRender.call(this, attribs, changed);
|
|
4744
|
-
// }
|
|
4745
5996
|
}
|
|
4746
5997
|
|
|
4747
5998
|
'render'() {
|
|
@@ -4884,4 +6135,4 @@ class Solarite extends HTMLElementAutoDefine {
|
|
|
4884
6135
|
}
|
|
4885
6136
|
|
|
4886
6137
|
export default h;
|
|
4887
|
-
export { Fragment, Globals$1 as Globals, Solarite, Util as SolariteUtil, Template, assignAttributes, delve, getEventBinding, h, svg, toEl };
|
|
6138
|
+
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 };
|