solarite 0.2.4 → 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,16 +79,23 @@ export default class ExprPath {
73
79
  nodeMarkerPath;
74
80
 
75
81
 
76
- /** @type {?function} */
82
+ /** @type {?function} A function called by renderWatched() to update the value of this expression. */
77
83
  watchFunction
78
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
+
79
92
  /**
80
93
  * @param nodeBefore {Node}
81
94
  * @param nodeMarker {?Node}
82
- * @param type {PathType}
95
+ * @param type {ExprPathType}
83
96
  * @param attrName {?string}
84
97
  * @param attrValue {string[]} */
85
- constructor(nodeBefore, nodeMarker, type=PathType.Content, attrName=null, attrValue=null) {
98
+ constructor(nodeBefore, nodeMarker, type=ExprPathType.Content, attrName=null, attrValue=null) {
86
99
 
87
100
  // If path is a node.
88
101
  this.nodeBefore = nodeBefore;
@@ -90,7 +103,7 @@ export default class ExprPath {
90
103
  this.type = type;
91
104
  this.attrName = attrName;
92
105
  this.attrValue = attrValue;
93
- if (type === PathType.Multiple)
106
+ if (type === ExprPathType.AttribMultiple)
94
107
  this.attrNames = new Set();
95
108
  }
96
109
 
@@ -104,36 +117,27 @@ export default class ExprPath {
104
117
  * We should modify path.applyValueAttrib so it stores the procssed parts and then only calls
105
118
  * setAttribute() once all the pieces are in place.
106
119
  *
107
- * @param expr {Expr}
108
120
  * @param exprs {Expr[]}
109
- * @param exprIndex {int}
110
- * @param componentExprs {object}
111
- * @returns {int} */
112
- apply(expr, exprs=null, exprIndex=0, componentExprs={}) {
121
+ * @param freeNodeGroups {boolean} */
122
+ apply(exprs, freeNodeGroups=true) {
113
123
  switch (this.type) {
114
124
  case 1: // PathType.Content:
115
- this.applyNodes(expr);
125
+ this.applyNodes(exprs[0], freeNodeGroups);
116
126
  break;
117
127
  case 2: // PathType.Multiple:
118
- this.applyMultipleAttribs(this.nodeMarker, expr);
128
+ this.applyMultipleAttribs(this.nodeMarker, exprs[0]);
119
129
  break;
120
130
  case 5: // PathType.Comment:
121
131
  // Expressions inside Html comments. Deliberately empty because we won't waste time updating them.
122
132
  break;
123
133
  case 6: // PathType.Event:
124
- this.applyEventAttrib(this.nodeMarker, expr, this.parentNg.rootNg.root);
134
+ this.applyEventAttrib(this.nodeMarker, exprs[0], this.parentNg.rootNg.root);
125
135
  break;
126
- default:
127
- if (this.type === 4 /*PathType.Component*/ && this.nodeMarker !== this.parentNg.rootNg.root)
128
- componentExprs[this.attrName] = expr;
129
- else {
130
- // One attribute value may have multiple expressions. Here we apply them all at once.
131
- exprIndex = this.applyValueAttrib(this.nodeMarker, exprs || [expr], exprIndex);
132
- }
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);
133
139
  break;
134
140
  }
135
-
136
- return exprIndex;
137
141
  }
138
142
 
139
143
  /**
@@ -141,14 +145,16 @@ export default class ExprPath {
141
145
  * Called by applyExprs()
142
146
  * This function is recursive, as the functions it calls also call it.
143
147
  * @param expr {Expr}
148
+ * @param freeNodeGroups {boolean}
144
149
  * @return {Node[]} New Nodes created. */
145
- applyNodes(expr) {
150
+ applyNodes(expr, freeNodeGroups=true) {
146
151
  let path = this;
147
152
 
148
153
  // This can be done at the beginning or the end of this function.
149
154
  // If at the end, we may get rendering done faster.
150
155
  // But when at the beginning, it leaves all the nodes in-use so we can do a renderWatched().
151
- path.freeNodeGroups();
156
+ if (freeNodeGroups)
157
+ path.freeNodeGroups();
152
158
 
153
159
  /*#IFDEV*/path.verify();/*#ENDIF*/
154
160
 
@@ -158,10 +164,10 @@ export default class ExprPath {
158
164
  /*#IFDEV*/assert(!oldNodeGroups.includes(null))/*#ENDIF*/
159
165
  let secondPass = []; // indices
160
166
 
161
- path.nodeGroups = []; // Reset before applyExact and the code below rebuilds it.
162
- path.applyExact(expr, newNodes, secondPass);
167
+ path.nodeGroups = []; // Reset before applyExactNodes and the code below rebuilds it.
168
+ path.applyExactNodes(expr, newNodes, secondPass);
163
169
 
164
- this.existingTextNodes = null;
170
+ //this.existingTextNodes = null;
165
171
 
166
172
  // TODO: Create an array of old vs Nodes and NodeGroups together.
167
173
  // If they're all the same, skip the next steps.
@@ -220,7 +226,7 @@ export default class ExprPath {
220
226
 
221
227
  for (let ng of oldNodeGroups)
222
228
  if (!ng.startNode.parentNode)
223
- ng.saveOrphans();
229
+ ng.removeAndSaveOrphans();
224
230
  }
225
231
 
226
232
 
@@ -228,49 +234,148 @@ export default class ExprPath {
228
234
  }
229
235
 
230
236
  /**
231
- * Used by watch() for replacing individual loop items. */
232
- applyLoopItemUpdate(index, template) {
233
- // At this point none of the nodes being used will be in nodeGroupsFree.
234
- let oldNg = this.nodeGroups[index];
235
- this.nodeGroupsFree.add(oldNg.exactKey, oldNg);
236
- this.nodeGroupsFree.add(oldNg.closeKey, oldNg);
237
-
238
- let ng = this.getNodeGroup(template, true);
239
- if (ng) {
240
- return; // It's an exactl match, so replace nothing.
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
+ }
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);
241
284
  }
242
285
 
286
+ // Add extra at the end.
287
+ else {
288
+ let newItems = op.items.slice(replaceCount);
243
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.
244
292
 
245
- ng = this.getNodeGroup(template, false);
246
293
 
247
- this.nodeGroups[index] = ng;
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()
248
299
 
249
- // Splice in the new nodes.
250
- for (let node of ng.getNodes()) {
251
- oldNg.startNode.parentNode.insertBefore(node, oldNg.startNode);
252
- }
300
+ this.nodeGroups.push(ng);
253
301
 
254
- if (oldNg !== ng) {
255
- for (let node of oldNg.getNodes())
256
- node.remove();
257
- oldNg.saveOrphans();
302
+ // Splice in the new nodes.
303
+ for (let node of ng.getNodes())
304
+ insertBefore.parentNode.insertBefore(node, insertBefore);
305
+ }
258
306
  }
259
307
 
308
+ //#IFDEV
309
+ assert(this.nodeGroups.length === op.array.length);
310
+ //#ENDIF
311
+
260
312
  // TODO: update or invalidate the nodes cache?
261
313
  this.nodesCache = null;
262
314
  }
263
315
 
316
+ /**
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
+
264
366
 
265
367
  /**
266
- * Apply Nodes that are an exact match.
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
+ *
267
372
  * @param expr {Template|Node|Array|function|*}
268
- * @param newNodes {(Node|Template)[]}
269
- * @param secondPass {Array} Locations within newNodes to evaluate later. */
270
- 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) {
271
377
 
272
378
  if (expr instanceof Template) {
273
-
274
379
  let ng = this.getNodeGroup(expr, true);
275
380
  if (ng) {
276
381
 
@@ -288,7 +393,7 @@ export default class ExprPath {
288
393
  }
289
394
  }
290
395
 
291
- // Node created by an expression.
396
+ // Node(s) created by an expression.
292
397
  else if (expr instanceof Node) {
293
398
 
294
399
  // DocumentFragment created by an expression.
@@ -301,53 +406,54 @@ export default class ExprPath {
301
406
  // Arrays and functions.
302
407
  // I tried iterating over the result of a generator function to avoid this recursion and simplify the code,
303
408
  // but that consistently made the js-framework-benchmarks a few percentage points slower.
304
- 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))
305
418
  for (let subExpr of expr)
306
- this.applyExact(subExpr, newNodes, secondPass);
419
+ this.applyExactNodes(subExpr, newNodes, secondPass);
307
420
 
308
421
  else if (typeof expr === 'function') {
309
422
  // TODO: One ExprPath can have multiple expr functions.
310
423
  // But if using it as a watch, it should only have one at the top level.
311
424
  // So maybe this is ok.
312
- Globals.currentExprPath = [this, expr]; // Used by watch3()
425
+ Globals.currentExprPath = this; // Used by watch()
426
+
313
427
  this.watchFunction = expr; // TODO: Only do this if it's a top level function.
314
- let result = expr();
428
+ let result = expr(); // As expr accesses watched variables, watch() uses Globals.currentExprPath to mark where those watched variables are being used.
315
429
  Globals.currentExprPath = null;
316
430
 
317
- this.applyExact(result, newNodes, secondPass);
431
+ this.applyExactNodes(result, newNodes, secondPass);
318
432
  }
319
433
 
320
- // Text
434
+ // String
321
435
  else {
322
- // Convert falsy values (but not 0) to empty string.
323
- // Convert numbers to string so they compare the same.
324
- let text = (expr === undefined || expr === false || expr === null) ? '' : (expr + '');
325
-
326
- // Fast path for updating the text of a single text node.
327
- let first = this.nodeBefore.nextSibling;
328
- if (first.nodeType === 3 && first.nextSibling === this.nodeMarker && !newNodes.includes(first)) {
329
- if (first.textContent !== text)
330
- first.textContent = text;
331
-
332
- 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;
333
448
  }
334
449
 
335
- else {
336
- // TODO: Optimize this into a Set or Map or something?
337
- if (!this.existingTextNodes)
338
- this.existingTextNodes = this.getNodes().filter(n => n.nodeType === 3);
339
-
340
- let idx = this.existingTextNodes.findIndex(n => n.textContent === text);
341
- if (idx !== -1)
342
- newNodes.push(...this.existingTextNodes.splice(idx, 1))
343
- else
344
- newNodes.push(this.nodeMarker.ownerDocument.createTextNode(text));
345
- }
346
- }
450
+ // Recurse.
451
+ this.applyExactNodes(template, newNodes, secondPass);
452
+ }*/
347
453
  }
348
454
 
349
455
  applyMultipleAttribs(node, expr) {
350
- /*#IFDEV*/assert(this.type === PathType.Multiple);/*#ENDIF*/
456
+ /*#IFDEV*/assert(this.type === ExprPathType.AttribMultiple);/*#ENDIF*/
351
457
 
352
458
  if (Array.isArray(expr))
353
459
  expr = expr.flat().join(' '); // flat and join so we can accept arrays of arrays of strings.
@@ -356,6 +462,13 @@ export default class ExprPath {
356
462
  let oldNames = this.attrNames;
357
463
  this.attrNames = new Set();
358
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
+
359
472
  let attrs = (expr +'') // Split string into multiple attributes.
360
473
  .split(/([\w-]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s]+))/g)
361
474
  .map(text => text.trim())
@@ -386,38 +499,38 @@ export default class ExprPath {
386
499
  * @param root */
387
500
  applyEventAttrib(node, expr, root) {
388
501
  /*#IFDEV*/
389
- assert(this.type === PathType.Event/* || this.type === PathType.Component*/);
502
+ assert(this.type === ExprPathType.Event/* || this.type === PathType.Component*/);
390
503
  assert(root instanceof HTMLElement);
391
504
  /*#ENDIF*/
392
505
 
393
506
  let eventName = this.attrName.slice(2); // remove "on-" prefix.
394
507
  let func;
395
-
396
- // Convert array to function.
397
508
  let args = [];
398
- if (Array.isArray(expr)) {
399
-
400
- // oninput=${[this.doSomething, 'meow']}
401
- if (typeof expr[0] === 'function') {
402
- func = expr[0];
403
- args = expr.slice(1);
404
- }
405
509
 
406
- // oninput=${[this, 'value']}
407
- else {
408
- func = setValue;
409
- args = [expr[0], expr.slice(1), node]
410
- node.value = delve(expr[0], expr.slice(1));
411
- // root.render(); // TODO: This causes infinite recursion.
412
- }
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);
413
515
  }
414
- else
516
+ else if (typeof expr === 'function')
415
517
  func = expr;
518
+ else
519
+ throw new Error(`Invalid event binding: <${node.tagName.toLowerCase()} ${this.attrName}=\${${JSON.stringify(expr)}}>`);
416
520
 
417
521
  this.bindEvent(node, root, eventName, eventName, func, args);
418
522
  }
419
523
 
420
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} */
421
534
  bindEvent(node, root, key, eventName, func, args, capture=false) {
422
535
  let nodeEvents = Globals.nodeEvents.get(node);
423
536
  if (!nodeEvents) {
@@ -428,10 +541,15 @@ export default class ExprPath {
428
541
  if (!nodeEvent)
429
542
  nodeEvents[key] = nodeEvent = new Array(3);
430
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.`);
431
546
 
432
547
  // If function has changed, remove and rebind the event.
433
548
  if (nodeEvent[0] !== func) {
434
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.
435
553
  let [existing, existingBound, _] = nodeEvent;
436
554
  if (existing)
437
555
  node.removeEventListener(eventName, existingBound, capture);
@@ -461,68 +579,144 @@ export default class ExprPath {
461
579
  nodeEvents[key][2] = args;
462
580
  }
463
581
 
464
- applyValueAttrib(node, exprs, exprIndex) {
465
- let expr = exprs[exprIndex];
466
-
467
- // Values to toggle an attribute
468
- if (!this.attrValue && (expr === false || expr === null || expr === undefined))
469
- node.removeAttribute(this.attrName);
470
-
471
- else if (!this.attrValue && expr === true)
472
- 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];
473
589
 
590
+ // Two-way binding between attributes
474
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']}
475
595
  // This same logic is in NodeGroup.createNewComponent() for components.
476
- else if ((this.attrName === 'value' || this.attrName === 'data-value') && Util.isPath(expr)) {
596
+ if (Util.isPath(expr)) {
477
597
  let [obj, path] = [expr[0], expr.slice(1)];
478
- node.value = delve(obj, path);
479
- // TODO: We need to remove any old listeners, like in bindEventAttribute
480
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?
481
623
  let func = () => {
482
- delve(obj, path, Util.getInputValue(node));
624
+ let value = (this.attrName === 'value')
625
+ ? Util.getInputValue(node)
626
+ : node[this.attrName];
627
+ delve(obj, path, value);
483
628
  }
484
629
 
485
630
  // We use capture so we update the values before other events added by the user.
486
- this.bindEvent(node, path[0], 'value', 'input', func, [], true);
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);
487
634
  }
488
635
 
489
636
  // Regular attribute
490
637
  else {
491
- let value = [];
492
-
493
- // We go backward because NodeGroup.applyExprs() calls this function, and it goes backward through the exprs.
494
- if (this.attrValue) {
495
- for (let i=this.attrValue.length-1; i>=0; i--) {
496
- value.unshift(this.attrValue[i]);
497
- if (i > 0) {
498
- let val = exprs[exprIndex];
499
- if (val !== false && val !== null && val !== undefined)
500
- value.unshift(val);
501
- 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
502
659
  }
660
+ this.watchFunction = expr; // The function that gets the expression, used for renderWatched()
661
+ expr = expr();
503
662
  }
504
- 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, '');
505
676
  }
506
- else
507
- value.unshift(expr);
508
677
 
509
- let joinedValue = value.join('')
678
+ // A non-toggled attribute
679
+ else {
510
680
 
511
- // Only update attributes if the value has changed.
512
- // The .value property is special. If it changes we don't update the attribute.
513
- let oldVal = this.attrName === 'value' ? node.value : node.getAttribute(this.attrName);
514
- if (oldVal !== joinedValue) {
515
- node.setAttribute(this.attrName, joinedValue);
516
- }
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
+ }
517
697
 
518
- // This is needed for setting input.value, .checked, option.selected, etc.
519
- // But in some cases setting the attribute is enough. such as div.setAttribute('title') updates div.title.
520
- // TODO: How to tell which is which?
521
- if (this.attrName in node)
522
- 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
+ }
523
719
  }
524
-
525
- return exprIndex;
526
720
  }
527
721
 
528
722
 
@@ -538,7 +732,8 @@ export default class ExprPath {
538
732
  let nodeMarker, nodeBefore;
539
733
  let root = newRoot;
540
734
  let path = pathOffset ? this.nodeMarkerPath.slice(0, -pathOffset) : this.nodeMarkerPath;
541
- 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.
542
737
  root = root.childNodes[path[i]];
543
738
  let childNodes = root.childNodes;
544
739
 
@@ -582,7 +777,7 @@ export default class ExprPath {
582
777
  for (let ng of path.nodeGroups) {
583
778
  if (ng) // Can be null from apply()'s push(null) call.
584
779
  for (let path2 of ng.paths) {
585
- if (path2.type === PathType.Content && path2.parentNode === parentNode) {
780
+ if (path2.type === ExprPathType.Content && path2.parentNode === parentNode) {
586
781
  path2.nodesCache = null;
587
782
  clearChildNodeCache(path2);
588
783
  }
@@ -597,7 +792,7 @@ export default class ExprPath {
597
792
 
598
793
  /**
599
794
  * Attempt to remove all of this ExprPath's nodes from the DOM, if it can be done using a special fast method.
600
- * @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. */
601
796
  fastClear() {
602
797
  let parent = this.nodeBefore.parentNode;
603
798
  if (this.nodeBefore === parent.firstChild && this.nodeMarker === parent.lastChild) {
@@ -633,6 +828,10 @@ export default class ExprPath {
633
828
  // result2.push(...ng.getNodes())
634
829
  // return result2;
635
830
 
831
+ if (this.type === ExprPathType.AttribValue || this.type === ExprPathType.AttribMultiple || this.type === ExprPathType.ComponentAttribValue) {
832
+ return [this.nodeMarker];
833
+ }
834
+
636
835
 
637
836
  let result
638
837
 
@@ -659,7 +858,8 @@ export default class ExprPath {
659
858
  return result;
660
859
  }
661
860
 
662
- getParentNode() { // Same as this.parentNode
861
+ /** @return {HTMLElement|ParentNode} */
862
+ getParentNode() {
663
863
  return this.nodeMarker.parentNode
664
864
  }
665
865
 
@@ -676,28 +876,39 @@ export default class ExprPath {
676
876
  * or createa new NodeGroup from the template.
677
877
  * @return {NodeGroup} */
678
878
  getNodeGroup(template, exact=true) {
679
- //if (exact && this.nodeGroupsFree.isEmpty())
680
- // return null;
681
879
 
682
880
  let result;
881
+ let collection = this.nodeGroupsAttachedAvailable;
683
882
 
684
883
  // TODO: Would it be faster to maintain a separate list of detached nodegroups?
685
884
  if (exact) { // [below] parentElement will be null if the parent is a DocumentFragment
686
- result = this.nodeGroupsFree.deletePreferred(template.getExactKey(), ng=>ng.startNode.parentElement);
885
+ result = collection.deleteAny(template.getExactKey());
886
+ if (!result) { // try searching detached
887
+ collection = this.nodeGroupsDetachedAvailable;
888
+ result = collection.deleteAny(template.getExactKey());
889
+ }
890
+
687
891
  if (result) // also delete the matching close key.
688
- this.nodeGroupsFree.delete(template.getCloseKey(), result);
689
- else
892
+ collection.deleteSpecific(template.getCloseKey(), result);
893
+ else {
690
894
  return null;
895
+ }
691
896
  }
692
897
 
693
898
  // Find a close match.
694
899
  // This is a match that has matching html, but different expressions applied.
695
900
  // We can then apply the expressions to make it an exact match.
696
- else {
697
- result = this.nodeGroupsFree.deletePreferred(template.getCloseKey(), ng=>ng.startNode.parentElement)
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
+
698
909
  if (result) {
699
910
  /*#IFDEV*/assert(result.exactKey);/*#ENDIF*/
700
- this.nodeGroupsFree.delete(result.exactKey, result);
911
+ collection.deleteSpecific(result.exactKey, result);
701
912
 
702
913
  // Update this close match with the new expression values.
703
914
  result.applyExprs(template.exprs);
@@ -709,64 +920,70 @@ export default class ExprPath {
709
920
  result = new NodeGroup(template, this);
710
921
 
711
922
  // old:
712
- this.nodeGroupsInUse.push(result);
713
-
714
- // new:
715
- // let ngiu = this.nodeGroupsInUse;
716
- // ngiu.add(result.exactKey, result);
717
- // ngiu.add(result.closeKey, result);
923
+ this.nodeGroupsRendered.push(result);
718
924
 
719
925
  /*#IFDEV*/assert(result.parentPath);/*#ENDIF*/
720
926
  return result;
721
927
  }
722
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
+ }
723
934
 
724
935
  /**
936
+ * TODO: Rename this to nodeGroupsInUse, nodeGroupsAvialableAttached and nodeGroupsAvailableDetached?
937
+ * Nodes that have been used during the current render().
725
938
  * Used with getNodeGroup() and freeNodeGroups().
726
939
  * TODO: Use an array of WeakRef so the gc can collect them?
727
940
  * TODO: Put items back in nodeGroupsInUse after applyExpr() is called, not before.
728
941
  * @type {NodeGroup[]} */
729
- nodeGroupsInUse = [];
730
-
731
- /** @type {MultiValueMap<key:string, value:NodeGroup>} */
732
- //nodeGroupsInUse = new MultiValueMap();
942
+ nodeGroupsRendered = [];
733
943
 
734
944
  /**
945
+ * Nodes that were added to the web component during the last render(), but are available to be used again.
735
946
  * Used with getNodeGroup() and freeNodeGroups().
736
947
  * Each NodeGroup is here twice, once under an exact key, and once under the close key.
737
948
  * @type {MultiValueMap<key:string, value:NodeGroup>} */
738
- nodeGroupsFree = new MultiValueMap();
949
+ nodeGroupsAttachedAvailable = new MultiValueMap();
739
950
 
740
- nodeGroupsDetached = new MultiValueMap();
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();
741
955
 
742
956
 
743
957
  /**
744
- * 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.
745
960
  * TODO: this could run as needed in getNodeGroup? */
746
961
  freeNodeGroups() {
747
- // old:
748
-
749
- //this.nodeGroupsDetached = this.nodeGroupsFree;
750
- //this.nodeGroupsFree = new MultiValueMap();
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
+ }
751
973
 
752
- let ngf = this.nodeGroupsFree;
753
- for (let ng of this.nodeGroupsInUse) {
754
- ngf.add(ng.exactKey, ng);
755
- ngf.add(ng.closeKey, ng);
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);
756
980
  }
757
- this.nodeGroupsInUse = [];
758
-
759
- // new:
760
- // for (let key in this.nodeGroupsFree.data)
761
- // for (let item of this.nodeGroupsFree.data[key])
762
- // this.nodeGroupsInUse.add(key, item);
763
- //
764
- // this.nodeGroupsFree = this.nodeGroupsInUse;
765
- // this.nodeGroupsInUse = new MultiValueMap();
981
+
982
+ this.nodeGroupsRendered = [];
766
983
  }
767
984
 
768
985
  //#IFDEV
769
-
986
+
770
987
  get debug() {
771
988
  return [
772
989
  `parentNode: ${this.nodeBefore.parentNode?.tagName?.toLowerCase()}`,
@@ -779,7 +996,7 @@ export default class ExprPath {
779
996
  }), 1).flat()
780
997
  ]
781
998
  }
782
-
999
+
783
1000
  get debugNodes() {
784
1001
  // Clear nodesCache so that getNodes() manually gets the nodes.
785
1002
  let nc = this.nodesCache;
@@ -788,13 +1005,13 @@ export default class ExprPath {
788
1005
  this.nodesCache = nc;
789
1006
  return result;
790
1007
  }
791
-
1008
+
792
1009
  verify() {
793
1010
  if (!window.verify)
794
1011
  return;
795
1012
 
796
- assert(this.type!==PathType.Content || this.nodeBefore)
797
- 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)
798
1015
 
799
1016
  // Need either nodeMarker or parentNode
800
1017
  assert(this.nodeMarker)
@@ -803,10 +1020,10 @@ export default class ExprPath {
803
1020
  assert(!this.nodeMarker || this.nodeMarker.parentNode)
804
1021
 
805
1022
  // nodeBefore and nodeMarker must have same parent.
806
- assert(this.type!==PathType.Content || this.nodeBefore.parentNode === this.nodeMarker.parentNode)
1023
+ assert(this.type!==ExprPathType.Content || this.nodeBefore.parentNode === this.nodeMarker.parentNode)
807
1024
 
808
1025
  assert(this.nodeBefore !== this.nodeMarker)
809
- 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)
810
1027
 
811
1028
  // Detect cyclic parent and grandparent references.
812
1029
  assert(this.parentNg?.parentPath !== this)
@@ -819,10 +1036,10 @@ export default class ExprPath {
819
1036
  // Make sure the nodesCache matches the nodes.
820
1037
  this.checkNodesCache();
821
1038
  }
822
-
1039
+
823
1040
  checkNodesCache() {
824
1041
  return;
825
-
1042
+
826
1043
  // Make sure cache is accurate.
827
1044
  // If this is invalid, then perhaps another component append()'d one of our nodes to itself.
828
1045
  // Or perhaps one of our nodes is used in an expression more than once.
@@ -837,14 +1054,15 @@ export default class ExprPath {
837
1054
  nodes.push(current)
838
1055
  current = current.nextSibling
839
1056
  }
1057
+
1058
+ if (!arraySame(this.nodesCache, nodes))
1059
+ console.log(this.nodesCache, nodes)
840
1060
  assert(arraySame(this.nodesCache, nodes) === true);
841
1061
  }
842
1062
  }
843
1063
  //#ENDIF
844
1064
  }
845
1065
 
846
-
847
-
848
1066
  /**
849
1067
  *
850
1068
  * @param root
@@ -860,19 +1078,19 @@ function setValue(root, path, node) {
860
1078
  }
861
1079
 
862
1080
  /** @enum {int} */
863
- export const PathType = {
1081
+ export const ExprPathType = {
864
1082
  /** Child of a node */
865
1083
  Content: 1,
866
-
1084
+
867
1085
  /** One or more whole attributes */
868
- Multiple: 2,
869
-
1086
+ AttribMultiple: 2,
1087
+
870
1088
  /** Value of an attribute. */
871
- Value: 3,
872
-
1089
+ AttribValue: 3,
1090
+
873
1091
  /** Value of an attribute being passed to a component. */
874
- Component: 4,
875
-
1092
+ ComponentAttribValue: 4,
1093
+
876
1094
  /** Expressions inside Html comments. */
877
1095
  Comment: 5,
878
1096
 
@@ -898,11 +1116,9 @@ export function getNodePath(node) {
898
1116
  * Note that the path is backward, with the outermost element at the end.
899
1117
  * @param root {HTMLElement|Document|DocumentFragment|ParentNode}
900
1118
  * @param path {int[]}
901
- * @returns {Node|HTMLElement} */
1119
+ * @returns {Node|HTMLElement|HTMLStyleElement} */
902
1120
  export function resolveNodePath(root, path) {
903
1121
  for (let i=path.length-1; i>=0; i--)
904
1122
  root = root.childNodes[path[i]];
905
1123
  return root;
906
1124
  }
907
-
908
-