solarite 0.2.3 → 0.3.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.
@@ -6,6 +6,10 @@ import Template from "./Template.js";
6
6
  import Globals from "./Globals.js";
7
7
  import MultiValueMap from "../util/MultiValueMap.js";
8
8
  import udomdiff from "./udomdiff.js";
9
+ //import {ArraySpliceOp} from "./watch.js";
10
+ //#IFDEV
11
+ var exprPathId = 0;
12
+ //#ENDIF
9
13
 
10
14
  /**
11
15
  * Path to where an expression should be evaluated within a Shell or NodeGroup.
@@ -13,8 +17,12 @@ import udomdiff from "./udomdiff.js";
13
17
  * TODO: Make this based on parent and node instead of path? */
14
18
  export default class ExprPath {
15
19
 
20
+ //#IFDEV
21
+ eid = exprPathId++;
22
+ //#ENDIF
23
+
16
24
  /**
17
- * @type {PathType} */
25
+ * @type {ExprPathType} */
18
26
  type;
19
27
 
20
28
  // Used for attributes:
@@ -30,8 +38,6 @@ export default class ExprPath {
30
38
  * @type {Set<string>} Used for type=AttribType.Multiple to remember the attributes that were added. */
31
39
  attrNames;
32
40
 
33
-
34
-
35
41
  /**
36
42
  * @type {Node} Node that occurs before this ExprPath's first Node.
37
43
  * This is necessary because udomdiff() can steal nodes from another ExprPath.
@@ -73,13 +79,23 @@ export default class ExprPath {
73
79
  nodeMarkerPath;
74
80
 
75
81
 
82
+ /** @type {?function} A function called by renderWatched() to update the value of this expression. */
83
+ watchFunction
84
+
85
+ /**
86
+ * @type {?function} The most recent callback passed to a .map() function in this ExprPath.
87
+ * TODO: What if one ExprPath has two .map() calls? Maybe we just won't support that. */
88
+ mapCallback
89
+
90
+ isHtmlProperty = undefined;
91
+
76
92
  /**
77
93
  * @param nodeBefore {Node}
78
94
  * @param nodeMarker {?Node}
79
- * @param type {PathType}
95
+ * @param type {ExprPathType}
80
96
  * @param attrName {?string}
81
97
  * @param attrValue {string[]} */
82
- constructor(nodeBefore, nodeMarker, type=PathType.Content, attrName=null, attrValue=null) {
98
+ constructor(nodeBefore, nodeMarker, type=ExprPathType.Content, attrName=null, attrValue=null) {
83
99
 
84
100
  // If path is a node.
85
101
  this.nodeBefore = nodeBefore;
@@ -87,7 +103,7 @@ export default class ExprPath {
87
103
  this.type = type;
88
104
  this.attrName = attrName;
89
105
  this.attrValue = attrValue;
90
- if (type === PathType.Multiple)
106
+ if (type === ExprPathType.AttribMultiple)
91
107
  this.attrNames = new Set();
92
108
  }
93
109
 
@@ -101,36 +117,27 @@ export default class ExprPath {
101
117
  * We should modify path.applyValueAttrib so it stores the procssed parts and then only calls
102
118
  * setAttribute() once all the pieces are in place.
103
119
  *
104
- * @param expr {Expr}
105
120
  * @param exprs {Expr[]}
106
- * @param exprIndex {int}
107
- * @param componentExprs {object}
108
- * @returns {int} */
109
- apply(expr, exprs=null, exprIndex=0, componentExprs={}) {
121
+ * @param freeNodeGroups {boolean} */
122
+ apply(exprs, freeNodeGroups=true) {
110
123
  switch (this.type) {
111
124
  case 1: // PathType.Content:
112
- this.applyNodes(expr);
125
+ this.applyNodes(exprs[0], freeNodeGroups);
113
126
  break;
114
127
  case 2: // PathType.Multiple:
115
- this.applyMultipleAttribs(this.nodeMarker, expr);
128
+ this.applyMultipleAttribs(this.nodeMarker, exprs[0]);
116
129
  break;
117
130
  case 5: // PathType.Comment:
118
131
  // Expressions inside Html comments. Deliberately empty because we won't waste time updating them.
119
132
  break;
120
133
  case 6: // PathType.Event:
121
- this.applyEventAttrib(this.nodeMarker, expr, this.parentNg.rootNg.root);
134
+ this.applyEventAttrib(this.nodeMarker, exprs[0], this.parentNg.rootNg.root);
122
135
  break;
123
- default:
124
- if (this.type === 4 /*PathType.Component*/ && this.nodeMarker !== this.parentNg.rootNg.root)
125
- componentExprs[this.attrName] = expr;
126
- else {
127
- // One attribute value may have multiple expressions. Here we apply them all at once.
128
- exprIndex = this.applyValueAttrib(this.nodeMarker, exprs || [expr], exprIndex);
129
- }
136
+ default: // TODO: Is this still used? Lots of tests fail without it.
137
+ // One attribute value may have multiple expressions. Here we apply them all at once.
138
+ this.applyValueAttrib(this.nodeMarker, exprs);
130
139
  break;
131
140
  }
132
-
133
- return exprIndex;
134
141
  }
135
142
 
136
143
  /**
@@ -138,10 +145,17 @@ export default class ExprPath {
138
145
  * Called by applyExprs()
139
146
  * This function is recursive, as the functions it calls also call it.
140
147
  * @param expr {Expr}
148
+ * @param freeNodeGroups {boolean}
141
149
  * @return {Node[]} New Nodes created. */
142
- applyNodes(expr) {
150
+ applyNodes(expr, freeNodeGroups=true) {
143
151
  let path = this;
144
152
 
153
+ // This can be done at the beginning or the end of this function.
154
+ // If at the end, we may get rendering done faster.
155
+ // But when at the beginning, it leaves all the nodes in-use so we can do a renderWatched().
156
+ if (freeNodeGroups)
157
+ path.freeNodeGroups();
158
+
145
159
  /*#IFDEV*/path.verify();/*#ENDIF*/
146
160
 
147
161
  /** @type {(Node|NodeGroup|Expr)[]} */
@@ -150,10 +164,10 @@ export default class ExprPath {
150
164
  /*#IFDEV*/assert(!oldNodeGroups.includes(null))/*#ENDIF*/
151
165
  let secondPass = []; // indices
152
166
 
153
- path.nodeGroups = []; // Reset before applyExact and the code below rebuilds it.
154
- path.applyExact(expr, newNodes, secondPass);
167
+ path.nodeGroups = []; // Reset before applyExactNodes and the code below rebuilds it.
168
+ path.applyExactNodes(expr, newNodes, secondPass);
155
169
 
156
- this.existingTextNodes = null;
170
+ //this.existingTextNodes = null;
157
171
 
158
172
  // TODO: Create an array of old vs Nodes and NodeGroups together.
159
173
  // If they're all the same, skip the next steps.
@@ -206,26 +220,162 @@ export default class ExprPath {
206
220
  // Rearrange nodes.
207
221
  udomdiff(path.nodeMarker.parentNode, oldNodes, newNodes, path.nodeMarker)
208
222
 
209
- Util.saveOrphans(oldNodeGroups, oldNodes);
223
+ // TODO: Put this in a remove() function of NodeGroup.
224
+ // Then only run it on the old nodeGroups that were actually removed.
225
+ //Util.saveOrphans(oldNodeGroups, oldNodes);
226
+
227
+ for (let ng of oldNodeGroups)
228
+ if (!ng.startNode.parentNode)
229
+ ng.removeAndSaveOrphans();
210
230
  }
211
231
 
212
- // Must happen after second pass.
213
- path.freeNodeGroups();
214
232
 
215
233
  /*#IFDEV*/path.verify();/*#ENDIF*/
216
234
  }
217
235
 
236
+ /**
237
+ * Used by watch() for inserting/removing/replacing individual loop items.
238
+ * @param op {ArraySpliceOp} */
239
+ applyArrayOp(op) {
240
+
241
+ // Replace NodeGroups
242
+ let replaceCount = Math.min(op.deleteCount, op.items.length);
243
+ let deleteCount = op.deleteCount - replaceCount;
244
+ for (let i=0; i<replaceCount; i++) {
245
+ let oldNg = this.nodeGroups[op.index + i]; // TODO: One expr can create multiple nodegroups.
246
+
247
+ // Try to find an exact match
248
+ let func = this.mapCallback || this.watchFunction;
249
+ let expr = func(op.items[i]);
250
+
251
+ // If the result of func isn't a template, conver it to one or more templates.
252
+ this.exprToTemplates(expr, template => { // TODO: An expr can create multiple NodeGroups. I need a way to group them.
253
+
254
+ let ng = this.getNodeGroup(template, true); // Removes from nodeGroupsAttached and adds to nodeGroupsRendered()
255
+ if (ng && ng === oldNg) {
256
+ // It's an exact match, so replace nothing.
257
+ // TODO: What if the found NodeGroup as at a differnet place?
258
+ } else {
259
+
260
+ // Find a close match or create a new node group
261
+ if (!ng)
262
+ ng = this.getNodeGroup(template, false); // adds back to nodeGroupsRendered()
263
+ this.nodeGroups[op.index + i] = ng; // TODO: Remove old one to nodeGroupsDetached?
264
+
265
+ // Splice in the new nodes.
266
+ let insertBefore = oldNg.startNode;
267
+ for (let node of ng.getNodes())
268
+ insertBefore.parentNode.insertBefore(node, insertBefore);
269
+
270
+ // Remove the old nodes.
271
+ if (ng !== oldNg)
272
+ oldNg.removeAndSaveOrphans();
273
+ }
274
+ });
275
+ }
218
276
 
277
+ // Delete extra at the end.
278
+ if (deleteCount > 0) {
279
+ for (let i=0; i<deleteCount; i++) {
280
+ let oldNg = this.nodeGroups[op.index + replaceCount + i];
281
+ oldNg.removeAndSaveOrphans();
282
+ }
283
+ this.nodeGroups.splice(op.index + replaceCount, deleteCount);
284
+ }
285
+
286
+ // Add extra at the end.
287
+ else {
288
+ let newItems = op.items.slice(replaceCount);
289
+
290
+ let insertBefore = this.nodeGroups[op.index + replaceCount]?.startNode || this.nodeMarker;
291
+ for (let i = 0; i < newItems.length; i++) { // We use nodeMarker if the subequent (or all) nodeGroups have been removed.
292
+
293
+
294
+ // Try to find exact match
295
+ let template = this.mapCallback(newItems[i]);
296
+ let ng = this.getNodeGroup(template, true); // Removes from nodeGroupsAttached and adds to nodeGroupsRendered()
297
+ if (!ng) // Find a close match or create a new node group
298
+ ng = this.getNodeGroup(template, false); // adds back to nodeGroupsRendered()
299
+
300
+ this.nodeGroups.push(ng);
301
+
302
+ // Splice in the new nodes.
303
+ for (let node of ng.getNodes())
304
+ insertBefore.parentNode.insertBefore(node, insertBefore);
305
+ }
306
+ }
307
+
308
+ //#IFDEV
309
+ assert(this.nodeGroups.length === op.array.length);
310
+ //#ENDIF
311
+
312
+ // TODO: update or invalidate the nodes cache?
313
+ this.nodesCache = null;
314
+ }
219
315
 
220
316
  /**
221
- * Apply Nodes that are an exact match.
317
+ * Recursively traverse expr.
318
+ * If a value is a function, evaluate it.
319
+ * If a value is an array, recurse on each item.
320
+ * If it's a primitive, convert it to a Template.
321
+ * Otherwise pass the item (which is now either a Template or a Node) to callback.
322
+ * @param expr
323
+ * @param callback {function(Node|Template)}
324
+ *
325
+ * TODO: have applyExactNodes() use this function. */
326
+ exprToTemplates(expr, callback) {
327
+ if (Array.isArray(expr))
328
+ for (let subExpr of expr)
329
+ this.exprToTemplates(subExpr, callback);
330
+
331
+ else if (typeof expr === 'function') {
332
+ // TODO: One ExprPath can have multiple expr functions.
333
+ // But if using it as a watch, it should only have one at the top level.
334
+ // So maybe this is ok.
335
+ Globals.currentExprPath = this; // Used by watch()
336
+
337
+ this.watchFunction = expr; // TODO: Only do this if it's a top level function.
338
+ expr = expr(); // As expr accesses watched variables, watch() uses Globals.currentExprPath to mark where those watched variables are being used.
339
+ Globals.currentExprPath = null;
340
+
341
+ this.exprToTemplates(expr, callback);
342
+ }
343
+
344
+ // String/Number/Date/Boolean
345
+ else if (!(expr instanceof Template) && !(expr instanceof Node)){
346
+ // Convert expression to a string.
347
+ if (expr === undefined || expr === false || expr === null) // Util.isFalsy() inlined
348
+ expr = '';
349
+ else if (typeof expr !== 'string')
350
+ expr += '';
351
+
352
+ // Get the same Template for the same string each time.
353
+ // let template = Globals.stringTemplates[expr];
354
+ // if (!template) {
355
+ let template = new Template([expr], []);
356
+ // Globals.stringTemplates[expr] = template;
357
+ //}
358
+
359
+ // Recurse.
360
+ this.exprToTemplates(template, callback);
361
+ }
362
+ else
363
+ callback(expr);
364
+ }
365
+
366
+
367
+ /**
368
+ * Try to apply Nodes that are an exact match, by finding existing nodes from the last render
369
+ * that have the same value as created by the expr.
370
+ * This is called from ExprPath.applyNodes().
371
+ *
222
372
  * @param expr {Template|Node|Array|function|*}
223
- * @param newNodes {(Node|Template)[]}
224
- * @param secondPass {Array} Locations within newNodes to evaluate later. */
225
- applyExact(expr, newNodes, secondPass) {
373
+ * @param newNodes {(Node|Template)[]} An inout parameter; we add the nodes here as we go.
374
+ * @param secondPass {[int, int][]} Locations within newNodes for ExprPath.applyNodes() to evaluate later,
375
+ * when it tries to find partial matches. */
376
+ applyExactNodes(expr, newNodes, secondPass) {
226
377
 
227
378
  if (expr instanceof Template) {
228
-
229
379
  let ng = this.getNodeGroup(expr, true);
230
380
  if (ng) {
231
381
 
@@ -243,7 +393,7 @@ export default class ExprPath {
243
393
  }
244
394
  }
245
395
 
246
- // Node created by an expression.
396
+ // Node(s) created by an expression.
247
397
  else if (expr instanceof Node) {
248
398
 
249
399
  // DocumentFragment created by an expression.
@@ -256,49 +406,54 @@ export default class ExprPath {
256
406
  // Arrays and functions.
257
407
  // I tried iterating over the result of a generator function to avoid this recursion and simplify the code,
258
408
  // but that consistently made the js-framework-benchmarks a few percentage points slower.
259
- else if (Array.isArray(expr))
409
+ else {
410
+ this.exprToTemplates(expr, template => {
411
+ this.applyExactNodes(template, newNodes, secondPass);
412
+ })
413
+
414
+ }
415
+
416
+ // Old version
417
+ /*else if (Array.isArray(expr))
260
418
  for (let subExpr of expr)
261
- this.applyExact(subExpr, newNodes, secondPass);
419
+ this.applyExactNodes(subExpr, newNodes, secondPass);
262
420
 
263
421
  else if (typeof expr === 'function') {
264
- Globals.currentExprPath = [this, expr]; // Used by watch3()
265
- let result = expr();
422
+ // TODO: One ExprPath can have multiple expr functions.
423
+ // But if using it as a watch, it should only have one at the top level.
424
+ // So maybe this is ok.
425
+ Globals.currentExprPath = this; // Used by watch()
426
+
427
+ this.watchFunction = expr; // TODO: Only do this if it's a top level function.
428
+ let result = expr(); // As expr accesses watched variables, watch() uses Globals.currentExprPath to mark where those watched variables are being used.
266
429
  Globals.currentExprPath = null;
267
430
 
268
- this.applyExact(result, newNodes, secondPass);
431
+ this.applyExactNodes(result, newNodes, secondPass);
269
432
  }
270
433
 
271
- // Text
434
+ // String
272
435
  else {
273
- // Convert falsy values (but not 0) to empty string.
274
- // Convert numbers to string so they compare the same.
275
- let text = (expr === undefined || expr === false || expr === null) ? '' : (expr + '');
276
-
277
- // Fast path for updating the text of a single text node.
278
- let first = this.nodeBefore.nextSibling;
279
- if (first.nodeType === 3 && first.nextSibling === this.nodeMarker && !newNodes.includes(first)) {
280
- if (first.textContent !== text)
281
- first.textContent = text;
282
-
283
- newNodes.push(first);
436
+ // Convert expression to a string.
437
+ let stringExpr = expr;
438
+ if (expr === undefined || expr === false || expr === null) // Util.isFalsy()
439
+ stringExpr = '';
440
+ else if (typeof expr !== 'string')
441
+ stringExpr = expr + '';
442
+
443
+ // Get the same Template for the same string each time.
444
+ let template = Globals.stringTemplates[stringExpr];
445
+ if (!template) {
446
+ template = new Template([stringExpr], []);
447
+ Globals.stringTemplates[stringExpr] = template;
284
448
  }
285
449
 
286
- else {
287
- // TODO: Optimize this into a Set or Map or something?
288
- if (!this.existingTextNodes)
289
- this.existingTextNodes = this.getNodes().filter(n => n.nodeType === 3);
290
-
291
- let idx = this.existingTextNodes.findIndex(n => n.textContent === text);
292
- if (idx !== -1)
293
- newNodes.push(...this.existingTextNodes.splice(idx, 1))
294
- else
295
- newNodes.push(this.nodeMarker.ownerDocument.createTextNode(text));
296
- }
297
- }
450
+ // Recurse.
451
+ this.applyExactNodes(template, newNodes, secondPass);
452
+ }*/
298
453
  }
299
454
 
300
455
  applyMultipleAttribs(node, expr) {
301
- /*#IFDEV*/assert(this.type === PathType.Multiple);/*#ENDIF*/
456
+ /*#IFDEV*/assert(this.type === ExprPathType.AttribMultiple);/*#ENDIF*/
302
457
 
303
458
  if (Array.isArray(expr))
304
459
  expr = expr.flat().join(' '); // flat and join so we can accept arrays of arrays of strings.
@@ -307,6 +462,13 @@ export default class ExprPath {
307
462
  let oldNames = this.attrNames;
308
463
  this.attrNames = new Set();
309
464
  if (expr) {
465
+ if (typeof expr === 'function') {
466
+ Globals.currentExprPath = this; // Used by watch()
467
+ this.watchFunction = expr; // used by renderWatched()
468
+ expr = expr();
469
+ Globals.currentExprPath = null;
470
+ }
471
+
310
472
  let attrs = (expr +'') // Split string into multiple attributes.
311
473
  .split(/([\w-]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s]+))/g)
312
474
  .map(text => text.trim())
@@ -337,49 +499,60 @@ export default class ExprPath {
337
499
  * @param root */
338
500
  applyEventAttrib(node, expr, root) {
339
501
  /*#IFDEV*/
340
- assert(this.type === PathType.Event/* || this.type === PathType.Component*/);
502
+ assert(this.type === ExprPathType.Event/* || this.type === PathType.Component*/);
341
503
  assert(root instanceof HTMLElement);
342
504
  /*#ENDIF*/
343
505
 
344
506
  let eventName = this.attrName.slice(2); // remove "on-" prefix.
345
507
  let func;
346
-
347
- // Convert array to function.
348
508
  let args = [];
349
- if (Array.isArray(expr)) {
350
509
 
351
- // oninput=${[this.doSomething, 'meow']}
352
- if (typeof expr[0] === 'function') {
353
- func = expr[0];
354
- args = expr.slice(1);
355
- }
356
-
357
- // Undocumented.
358
- // oninput=${[this, 'value']}
359
- else {
360
- func = setValue;
361
- args = [expr[0], expr.slice(1), node]
362
- node.value = delve(expr[0], expr.slice(1));
363
- // root.render(); // TODO: This causes infinite recursion.
364
- }
510
+ // Convert array to function.
511
+ // oninput=${[this.doSomething, 'meow']}
512
+ if (Array.isArray(expr) && typeof expr[0] === 'function') {
513
+ func = expr[0];
514
+ args = expr.slice(1);
365
515
  }
366
- else
516
+ else if (typeof expr === 'function')
367
517
  func = expr;
518
+ else
519
+ throw new Error(`Invalid event binding: <${node.tagName.toLowerCase()} ${this.attrName}=\${${JSON.stringify(expr)}}>`);
520
+
521
+ this.bindEvent(node, root, eventName, eventName, func, args);
522
+ }
523
+
368
524
 
525
+ /**
526
+ * Call function when eventName is triggerd on node.
527
+ * @param node {HTMLElement}
528
+ * @param root {HTMLElement}
529
+ * @param key {string}
530
+ * @param eventName {string}
531
+ * @param func {function}
532
+ * @param args {array}
533
+ * @param capture {boolean} */
534
+ bindEvent(node, root, key, eventName, func, args, capture=false) {
369
535
  let nodeEvents = Globals.nodeEvents.get(node);
370
536
  if (!nodeEvents) {
371
- nodeEvents = {[eventName]: new Array(3)};
537
+ nodeEvents = {[key]: new Array(3)};
372
538
  Globals.nodeEvents.set(node, nodeEvents);
373
539
  }
374
- let nodeEvent = nodeEvents[eventName];
375
-
540
+ let nodeEvent = nodeEvents[key];
541
+ if (!nodeEvent)
542
+ nodeEvents[key] = nodeEvent = new Array(3);
376
543
 
544
+ if (typeof func !== 'function')
545
+ throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${${func}}> because it's not a function.`);
377
546
 
378
547
  // If function has changed, remove and rebind the event.
379
548
  if (nodeEvent[0] !== func) {
549
+
550
+ // TODO: We should be removing event listeners when calling getNodeGroup(),
551
+ // when we get the node from the list of nodeGroupsAttached/nodeGroupsDetached,
552
+ // instead of only when we rebind an event.
380
553
  let [existing, existingBound, _] = nodeEvent;
381
554
  if (existing)
382
- node.removeEventListener(eventName, existingBound);
555
+ node.removeEventListener(eventName, existingBound, capture);
383
556
 
384
557
  let originalFunc = func;
385
558
 
@@ -395,74 +568,155 @@ export default class ExprPath {
395
568
  nodeEvent[0] = originalFunc;
396
569
  nodeEvent[1] = boundFunc;
397
570
 
398
- node.addEventListener(eventName, boundFunc);
571
+ node.addEventListener(eventName, boundFunc, capture);
399
572
 
400
573
  // TODO: classic event attribs?
401
- //el[attr.name] = e => // e.g. el.onclick = ...
402
- // (new Function('event', 'el', attr.value)).bind(this.manager.rootEl)(e, el) // put "event", "el", and "this" in scope for the event code.
574
+ //el[attr.name] = e => // e.g. el.onclick = ... // put "event", "el", and "this" in scope for the event code.
575
+ // (new Function('event', 'el', attr.value)).bind(this.manager.rootEl)(e, el)
403
576
  }
404
577
 
405
578
  // Otherwise just update the args to the function.
406
- nodeEvents[eventName][2] = args;
579
+ nodeEvents[key][2] = args;
407
580
  }
408
581
 
409
- applyValueAttrib(node, exprs, exprIndex) {
410
- let expr = exprs[exprIndex];
411
-
412
- // Values to toggle an attribute
413
- if (!this.attrValue && (expr === false || expr === null || expr === undefined))
414
- node.removeAttribute(this.attrName);
415
-
416
- else if (!this.attrValue && expr === true)
417
- node.setAttribute(this.attrName, '');
582
+ /**
583
+ * Handle values, including two-way binding.
584
+ * @param node
585
+ * @param exprs */
586
+ // TODO: node is always this.nodeMarker?
587
+ applyValueAttrib(node, exprs) {
588
+ let expr = exprs[0];
418
589
 
590
+ // Two-way binding between attributes
419
591
  // Passing a path to the value attribute.
592
+ // Copies the attribute to the property when the input event fires.
593
+ // value=${[this, 'value]'}
594
+ // checked=${[this, 'isAgree']}
420
595
  // This same logic is in NodeGroup.createNewComponent() for components.
421
- else if ((this.attrName === 'value' || this.attrName === 'data-value') && Util.isPath(expr)) {
596
+ if (Util.isPath(expr)) {
422
597
  let [obj, path] = [expr[0], expr.slice(1)];
423
- node.value = delve(obj, path);
424
- node.addEventListener('input', () => {
425
- delve(obj, path, Util.getInputValue(node));
426
- }, true); // We use capture so we update the values before other events added by the user.
598
+
599
+ if (!obj)
600
+ throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${[${expr.map(item => item ? `'${item}'` : item+'').join(', ')}]}>.`);
601
+
602
+ let value = delve(obj, path);
603
+
604
+ // Special case to allow setting select-multiple value from an array
605
+ if (this.attrName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
606
+ // Set the .selected property on the options having a value within value.
607
+ let strValues = value.map(v => v + '');
608
+ for (let option of node.options)
609
+ option.selected = strValues.includes(option.value)
610
+ }
611
+ else {
612
+ // TODO: should we remove isFalsy, since these are always props?
613
+ let strValue = Util.isFalsy(value) ? '' : value;
614
+
615
+ // If we don't have this condition, when we call render(), the browser will scroll to the currently
616
+ // selected item in a <select> and mess up manually scrolling to a different value.
617
+ if (strValue !== node[this.attrName])
618
+ node[this.attrName] = strValue;
619
+ }
620
+
621
+ // TODO: We need to remove any old listeners, like in bindEventAttribute.
622
+ // Does bindEvent() now handle that?
623
+ let func = () => {
624
+ let value = (this.attrName === 'value')
625
+ ? Util.getInputValue(node)
626
+ : node[this.attrName];
627
+ delve(obj, path, value);
628
+ }
629
+
630
+ // We use capture so we update the values before other events added by the user.
631
+ // TODO: Bind to scroll events also?
632
+ // What about resize events and width/height?
633
+ this.bindEvent(node, path[0], this.attrName, 'input', func, [], true);
427
634
  }
428
635
 
429
636
  // Regular attribute
430
637
  else {
431
- let value = [];
432
-
433
- // We go backward because NodeGroup.applyExprs() calls this function, and it goes backward through the exprs.
434
- if (this.attrValue) {
435
- for (let i=this.attrValue.length-1; i>=0; i--) {
436
- value.unshift(this.attrValue[i]);
437
- if (i > 0) {
438
- let val = exprs[exprIndex];
439
- if (val !== false && val !== null && val !== undefined)
440
- value.unshift(val);
441
- exprIndex--;
638
+ // TODO: Cache this on ExprPath.isProp when Shell creates the props. Have ExprPath.clone() copy .isProp
639
+ // Or make it a new PathType.
640
+ //if (this.attrName === 'disabled')
641
+ // debugger;
642
+
643
+ // hasOwnProperty() checks only the object, not the parents
644
+ // this.attrName in node checks the node and the parents.
645
+ // This version checks the html element it extends from, to see if has a setter set:
646
+ // Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), this.attrName)?.set
647
+ //let isProp = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), this.attrName)?.set;
648
+ let isProp = this.isHtmlProperty;
649
+ if (isProp === undefined)
650
+ isProp = this.isHtmlProperty = Util.isHtmlProp(node, this.attrName);
651
+
652
+ // Values to toggle an attribute
653
+ let multiple = this.attrValue;
654
+ if (!multiple) {
655
+ Globals.currentExprPath = this; // Used by watch()
656
+ if (typeof expr === 'function') {
657
+ if (this.type === 4) { // Don't evaluate functions before passing them to components
658
+ return
442
659
  }
660
+ this.watchFunction = expr; // The function that gets the expression, used for renderWatched()
661
+ expr = expr();
443
662
  }
444
- exprIndex ++;
663
+ else
664
+ expr = Util.makePrimitive(expr);
665
+ Globals.currentExprPath = null;
666
+ }
667
+ if (!multiple && (expr === undefined || expr === false || expr === null)) { // Util.isFalsy() inlined.
668
+ if (isProp)
669
+ node[this.attrName] = false;
670
+ node.removeAttribute(this.attrName);
671
+ }
672
+ else if (!multiple && expr === true) {
673
+ if (isProp)
674
+ node[this.attrName] = true;
675
+ node.setAttribute(this.attrName, '');
445
676
  }
446
- else
447
- value.unshift(expr);
448
677
 
449
- let joinedValue = value.join('')
678
+ // A non-toggled attribute
679
+ else {
450
680
 
451
- // Only update attributes if the value has changed.
452
- // The .value property is special. If it changes we don't update the attribute.
453
- let oldVal = this.attrName === 'value' ? node.value : node.getAttribute(this.attrName);
454
- if (oldVal !== joinedValue) {
455
- node.setAttribute(this.attrName, joinedValue);
456
- }
681
+ // If it's a series of expressions among strings, join them together.
682
+ let joinedValue;
683
+ if (multiple) {
684
+ let value = [];
685
+ for (let i = 0; i < this.attrValue.length; i++) {
686
+ value.push(this.attrValue[i]);
687
+ if (i < this.attrValue.length - 1) {
688
+ Globals.currentExprPath = this; // Used by watch()
689
+ let val = Util.makePrimitive(exprs[i]);
690
+ Globals.currentExprPath = null;
691
+ if (!Util.isFalsy(val))
692
+ value.push(val);
693
+ }
694
+ }
695
+ joinedValue = value.join('')
696
+ }
457
697
 
458
- // This is needed for setting input.value, .checked, option.selected, etc.
459
- // But in some cases setting the attribute is enough. such as div.setAttribute('title') updates div.title.
460
- // TODO: How to tell which is which?
461
- if (this.attrName in node)
462
- node[this.attrName] = joinedValue;
698
+ // If the attribute is one expression with no strings:
699
+ else
700
+ joinedValue = expr;
701
+
702
+ // Only update attributes if the value has changed.
703
+ // This is needed for setting input.value, .checked, option.selected, etc.
704
+
705
+ let oldVal = isProp
706
+ ? node[this.attrName]
707
+ : node.getAttribute(this.attrName);
708
+ if (oldVal !== joinedValue) {
709
+
710
+ // <textarea value=${expr}></textarea>
711
+ // Without this branch we have no way to set the value of a textarea,
712
+ // since we also prohibit expressions that are a child of textarea.
713
+ if (isProp)
714
+ node[this.attrName] = joinedValue;
715
+ // TODO: Putting an 'else' here would be more performant
716
+ node.setAttribute(this.attrName, joinedValue);
717
+ }
718
+ }
463
719
  }
464
-
465
- return exprIndex;
466
720
  }
467
721
 
468
722
 
@@ -478,7 +732,8 @@ export default class ExprPath {
478
732
  let nodeMarker, nodeBefore;
479
733
  let root = newRoot;
480
734
  let path = pathOffset ? this.nodeMarkerPath.slice(0, -pathOffset) : this.nodeMarkerPath;
481
- for (let i=path.length-1; i>0; i--) // Resolve the path.
735
+ let length = path.length-1;
736
+ for (let i=length; i>0; i--) // Resolve the path.
482
737
  root = root.childNodes[path[i]];
483
738
  let childNodes = root.childNodes;
484
739
 
@@ -522,7 +777,7 @@ export default class ExprPath {
522
777
  for (let ng of path.nodeGroups) {
523
778
  if (ng) // Can be null from apply()'s push(null) call.
524
779
  for (let path2 of ng.paths) {
525
- if (path2.type === PathType.Content && path2.parentNode === parentNode) {
780
+ if (path2.type === ExprPathType.Content && path2.parentNode === parentNode) {
526
781
  path2.nodesCache = null;
527
782
  clearChildNodeCache(path2);
528
783
  }
@@ -537,7 +792,7 @@ export default class ExprPath {
537
792
 
538
793
  /**
539
794
  * Attempt to remove all of this ExprPath's nodes from the DOM, if it can be done using a special fast method.
540
- * @returns {boolean} Returns false if Nodes werne't removed, and they should instead be removed manually. */
795
+ * @returns {boolean} Returns false if Nodes weren't removed, and they should instead be removed manually. */
541
796
  fastClear() {
542
797
  let parent = this.nodeBefore.parentNode;
543
798
  if (this.nodeBefore === parent.firstChild && this.nodeMarker === parent.lastChild) {
@@ -573,6 +828,10 @@ export default class ExprPath {
573
828
  // result2.push(...ng.getNodes())
574
829
  // return result2;
575
830
 
831
+ if (this.type === ExprPathType.AttribValue || this.type === ExprPathType.AttribMultiple || this.type === ExprPathType.ComponentAttribValue) {
832
+ return [this.nodeMarker];
833
+ }
834
+
576
835
 
577
836
  let result
578
837
 
@@ -599,7 +858,8 @@ export default class ExprPath {
599
858
  return result;
600
859
  }
601
860
 
602
- getParentNode() { // Same as this.parentNode
861
+ /** @return {HTMLElement|ParentNode} */
862
+ getParentNode() {
603
863
  return this.nodeMarker.parentNode
604
864
  }
605
865
 
@@ -616,27 +876,39 @@ export default class ExprPath {
616
876
  * or createa new NodeGroup from the template.
617
877
  * @return {NodeGroup} */
618
878
  getNodeGroup(template, exact=true) {
619
- //if (exact && this.nodeGroupsFree.isEmpty())
620
- // return null;
621
879
 
622
880
  let result;
881
+ let collection = this.nodeGroupsAttachedAvailable;
882
+
883
+ // TODO: Would it be faster to maintain a separate list of detached nodegroups?
884
+ if (exact) { // [below] parentElement will be null if the parent is a DocumentFragment
885
+ result = collection.deleteAny(template.getExactKey());
886
+ if (!result) { // try searching detached
887
+ collection = this.nodeGroupsDetachedAvailable;
888
+ result = collection.deleteAny(template.getExactKey());
889
+ }
623
890
 
624
- if (exact) {
625
- result = this.nodeGroupsFree.delete(template.getExactKey());
626
891
  if (result) // also delete the matching close key.
627
- this.nodeGroupsFree.delete(template.getCloseKey(), result);
628
- else
892
+ collection.deleteSpecific(template.getCloseKey(), result);
893
+ else {
629
894
  return null;
895
+ }
630
896
  }
631
897
 
632
898
  // Find a close match.
633
899
  // This is a match that has matching html, but different expressions applied.
634
900
  // We can then apply the expressions to make it an exact match.
635
- else {
636
- result = this.nodeGroupsFree.delete(template.getCloseKey())
901
+ // If the template has no expressions, the key is the html, and we've already searched for an exact match. There won't be an inexact match.
902
+ else if (template.exprs.length) {
903
+ result = collection.deleteAny(template.getCloseKey());
904
+ if (!result) { // try searching detached
905
+ collection = this.nodeGroupsDetachedAvailable;
906
+ result = collection.deleteAny(template.getCloseKey());
907
+ }
908
+
637
909
  if (result) {
638
910
  /*#IFDEV*/assert(result.exactKey);/*#ENDIF*/
639
- this.nodeGroupsFree.delete(result.exactKey, result);
911
+ collection.deleteSpecific(result.exactKey, result);
640
912
 
641
913
  // Update this close match with the new expression values.
642
914
  result.applyExprs(template.exprs);
@@ -648,58 +920,70 @@ export default class ExprPath {
648
920
  result = new NodeGroup(template, this);
649
921
 
650
922
  // old:
651
- this.nodeGroupsInUse.push(result);
652
-
653
- // new:
654
- // let ngiu = this.nodeGroupsInUse;
655
- // ngiu.add(result.exactKey, result);
656
- // ngiu.add(result.closeKey, result);
923
+ this.nodeGroupsRendered.push(result);
657
924
 
658
925
  /*#IFDEV*/assert(result.parentPath);/*#ENDIF*/
659
926
  return result;
660
927
  }
661
928
 
929
+ isComponent() {
930
+ // Events won't have type===Component.
931
+ // TODO: Have a special flag for components instead of it being on the type?
932
+ return this.type === ExprPathType.ComponentAttribValue || (this.attrName && this.nodeMarker.tagName && this.nodeMarker.tagName.includes('-'));
933
+ }
662
934
 
663
935
  /**
936
+ * TODO: Rename this to nodeGroupsInUse, nodeGroupsAvialableAttached and nodeGroupsAvailableDetached?
937
+ * Nodes that have been used during the current render().
664
938
  * Used with getNodeGroup() and freeNodeGroups().
665
939
  * TODO: Use an array of WeakRef so the gc can collect them?
666
940
  * TODO: Put items back in nodeGroupsInUse after applyExpr() is called, not before.
667
941
  * @type {NodeGroup[]} */
668
- nodeGroupsInUse = [];
669
-
670
- /** @type {MultiValueMap<key:string, value:NodeGroup>} */
671
- //nodeGroupsInUse = new MultiValueMap();
942
+ nodeGroupsRendered = [];
672
943
 
673
944
  /**
945
+ * Nodes that were added to the web component during the last render(), but are available to be used again.
674
946
  * Used with getNodeGroup() and freeNodeGroups().
675
947
  * Each NodeGroup is here twice, once under an exact key, and once under the close key.
676
948
  * @type {MultiValueMap<key:string, value:NodeGroup>} */
677
- nodeGroupsFree = new MultiValueMap();
949
+ nodeGroupsAttachedAvailable = new MultiValueMap();
950
+
951
+ /**
952
+ * Nodes that were not added to the web component during the last render(), and available to be used again.
953
+ * @type {MultiValueMap} */
954
+ nodeGroupsDetachedAvailable = new MultiValueMap();
678
955
 
679
956
 
680
957
  /**
681
- * Move everything from this.nodeGroupsInUse to this.nodeGroupsFree.
958
+ * Move everything from this.nodeGroupsRendered to this.nodeGroupsAttached and nodeGroupsDetached.
959
+ * Called at the beginning of applyNodes() so it can have NodeGroups to use.
682
960
  * TODO: this could run as needed in getNodeGroup? */
683
961
  freeNodeGroups() {
684
- // old:
685
- let ngf = this.nodeGroupsFree;
686
- for (let ng of this.nodeGroupsInUse) {
687
- ngf.add(ng.exactKey, ng);
688
- ngf.add(ng.closeKey, ng);
962
+ // Add nodes that weren't used during render() to nodeGroupsDetached
963
+ let previouslyAttached = this.nodeGroupsAttachedAvailable.data;
964
+ let detached = this.nodeGroupsDetachedAvailable.data;
965
+ for (let key in previouslyAttached) {
966
+ let set = detached[key];
967
+ if (!set)
968
+ detached[key] = previouslyAttached[key]
969
+ else
970
+ for (let ng of previouslyAttached[key])
971
+ set.add(ng);
972
+ }
973
+
974
+ // Add nodes that were used during render() to nodeGroupsRendered.
975
+ this.nodeGroupsAttachedAvailable = new MultiValueMap();
976
+ let nga = this.nodeGroupsAttachedAvailable;
977
+ for (let ng of this.nodeGroupsRendered) {
978
+ nga.add(ng.exactKey, ng);
979
+ nga.add(ng.closeKey, ng);
689
980
  }
690
- this.nodeGroupsInUse = [];
691
-
692
- // new:
693
- // for (let key in this.nodeGroupsFree.data)
694
- // for (let item of this.nodeGroupsFree.data[key])
695
- // this.nodeGroupsInUse.add(key, item);
696
- //
697
- // this.nodeGroupsFree = this.nodeGroupsInUse;
698
- // this.nodeGroupsInUse = new MultiValueMap();
981
+
982
+ this.nodeGroupsRendered = [];
699
983
  }
700
984
 
701
985
  //#IFDEV
702
-
986
+
703
987
  get debug() {
704
988
  return [
705
989
  `parentNode: ${this.nodeBefore.parentNode?.tagName?.toLowerCase()}`,
@@ -712,7 +996,7 @@ export default class ExprPath {
712
996
  }), 1).flat()
713
997
  ]
714
998
  }
715
-
999
+
716
1000
  get debugNodes() {
717
1001
  // Clear nodesCache so that getNodes() manually gets the nodes.
718
1002
  let nc = this.nodesCache;
@@ -721,13 +1005,13 @@ export default class ExprPath {
721
1005
  this.nodesCache = nc;
722
1006
  return result;
723
1007
  }
724
-
1008
+
725
1009
  verify() {
726
1010
  if (!window.verify)
727
1011
  return;
728
1012
 
729
- assert(this.type!==PathType.Content || this.nodeBefore)
730
- assert(this.type!==PathType.Content || this.nodeBefore.parentNode)
1013
+ assert(this.type!==ExprPathType.Content || this.nodeBefore)
1014
+ assert(this.type!==ExprPathType.Content || this.nodeBefore.parentNode)
731
1015
 
732
1016
  // Need either nodeMarker or parentNode
733
1017
  assert(this.nodeMarker)
@@ -736,10 +1020,10 @@ export default class ExprPath {
736
1020
  assert(!this.nodeMarker || this.nodeMarker.parentNode)
737
1021
 
738
1022
  // nodeBefore and nodeMarker must have same parent.
739
- assert(this.type!==PathType.Content || this.nodeBefore.parentNode === this.nodeMarker.parentNode)
1023
+ assert(this.type!==ExprPathType.Content || this.nodeBefore.parentNode === this.nodeMarker.parentNode)
740
1024
 
741
1025
  assert(this.nodeBefore !== this.nodeMarker)
742
- assert(this.type!==PathType.Content|| !this.nodeBefore.parentNode || this.nodeBefore.compareDocumentPosition(this.nodeMarker) === Node.DOCUMENT_POSITION_FOLLOWING)
1026
+ assert(this.type!==ExprPathType.Content|| !this.nodeBefore.parentNode || this.nodeBefore.compareDocumentPosition(this.nodeMarker) === Node.DOCUMENT_POSITION_FOLLOWING)
743
1027
 
744
1028
  // Detect cyclic parent and grandparent references.
745
1029
  assert(this.parentNg?.parentPath !== this)
@@ -752,10 +1036,10 @@ export default class ExprPath {
752
1036
  // Make sure the nodesCache matches the nodes.
753
1037
  this.checkNodesCache();
754
1038
  }
755
-
1039
+
756
1040
  checkNodesCache() {
757
1041
  return;
758
-
1042
+
759
1043
  // Make sure cache is accurate.
760
1044
  // If this is invalid, then perhaps another component append()'d one of our nodes to itself.
761
1045
  // Or perhaps one of our nodes is used in an expression more than once.
@@ -770,14 +1054,15 @@ export default class ExprPath {
770
1054
  nodes.push(current)
771
1055
  current = current.nextSibling
772
1056
  }
1057
+
1058
+ if (!arraySame(this.nodesCache, nodes))
1059
+ console.log(this.nodesCache, nodes)
773
1060
  assert(arraySame(this.nodesCache, nodes) === true);
774
1061
  }
775
1062
  }
776
1063
  //#ENDIF
777
1064
  }
778
1065
 
779
-
780
-
781
1066
  /**
782
1067
  *
783
1068
  * @param root
@@ -793,19 +1078,19 @@ function setValue(root, path, node) {
793
1078
  }
794
1079
 
795
1080
  /** @enum {int} */
796
- export const PathType = {
1081
+ export const ExprPathType = {
797
1082
  /** Child of a node */
798
1083
  Content: 1,
799
-
1084
+
800
1085
  /** One or more whole attributes */
801
- Multiple: 2,
802
-
1086
+ AttribMultiple: 2,
1087
+
803
1088
  /** Value of an attribute. */
804
- Value: 3,
805
-
1089
+ AttribValue: 3,
1090
+
806
1091
  /** Value of an attribute being passed to a component. */
807
- Component: 4,
808
-
1092
+ ComponentAttribValue: 4,
1093
+
809
1094
  /** Expressions inside Html comments. */
810
1095
  Comment: 5,
811
1096
 
@@ -831,11 +1116,9 @@ export function getNodePath(node) {
831
1116
  * Note that the path is backward, with the outermost element at the end.
832
1117
  * @param root {HTMLElement|Document|DocumentFragment|ParentNode}
833
1118
  * @param path {int[]}
834
- * @returns {Node|HTMLElement} */
1119
+ * @returns {Node|HTMLElement|HTMLStyleElement} */
835
1120
  export function resolveNodePath(root, path) {
836
1121
  for (let i=path.length-1; i>=0; i--)
837
1122
  root = root.childNodes[path[i]];
838
1123
  return root;
839
1124
  }
840
-
841
-