solarite 0.3.2 → 0.5.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.
@@ -0,0 +1,566 @@
1
+ import Path from "./Path.js";
2
+ import assert from "./assert.js";
3
+ import NodeGroup from "./NodeGroup.js";
4
+ import Util from "./Util.js";
5
+ import udomdiff from "./udomdiff.js";
6
+ import Template from "./Template.js";
7
+ import Globals from "./Globals.js";
8
+ import MultiValueMap from "./MultiValueMap.js";
9
+
10
+ export default class PathToNodes extends Path {
11
+
12
+
13
+ /**
14
+ * @type {?function} The most recent callback passed to a .map() function in this Path. This is only used for watch.js
15
+ * TODO: What if one Path has two .map() calls? Maybe we just won't support that. */
16
+ mapCallback;
17
+
18
+
19
+
20
+ /**
21
+ * TODO: Rename this to nodeGroupsInUse, nodeGroupsAvialableAttached and nodeGroupsAvailableDetached?
22
+ * Nodes that have been used during the current render().
23
+ * Used with getNodeGroup() and freeNodeGroups().
24
+ * TODO: Use an array of WeakRef so the gc can collect them?
25
+ * TODO: Put items back in nodeGroupsInUse after applyExpr() is called, not before.
26
+ * @type {NodeGroup[]} */
27
+ nodeGroupsRendered = [];
28
+
29
+ /**
30
+ * Nodes that were added to the web component during the last render(), but are available to be used again.
31
+ * Used with getNodeGroup() and freeNodeGroups().
32
+ * Each NodeGroup is here twice, once under an exact key, and once under the close key.
33
+ * @type {MultiValueMap<key:string, value:NodeGroup>} */
34
+ nodeGroupsAttachedAvailable = new MultiValueMap();
35
+
36
+ /**
37
+ * Nodes that were not added to the web component during the last render(), and available to be used again.
38
+ * @type {MultiValueMap} */
39
+ nodeGroupsDetachedAvailable = new MultiValueMap();
40
+
41
+ constructor(nodeBefore, nodeMarker) {
42
+ super(nodeBefore, nodeMarker);
43
+ }
44
+
45
+ /**
46
+ * Insert/replace the nodes created by a single expression.
47
+ * Called by applyExprs()
48
+ * This function is recursive. It calls functions that call applyNodes().
49
+ * @param exprs {Expr[]} Only the first is used.
50
+ * @param freeNodeGroups {boolean}
51
+ * @return {Node[]} New Nodes created. */
52
+ apply(exprs, freeNodeGroups=true) {
53
+ //#IFDEV
54
+ assert(Array.isArray(exprs));
55
+ //#ENDIF
56
+
57
+ let path = this;
58
+ let expr = exprs[0];
59
+
60
+ // This can be done at the beginning or the end of this function.
61
+ // If at the end, we may get rendering done faster.
62
+ // But when at the beginning, it leaves all the nodes in-use so we can do a renderWatched().
63
+ if (freeNodeGroups)
64
+ path.freeNodeGroups();
65
+
66
+ /*#IFDEV*/path.verify();/*#ENDIF*/
67
+
68
+ /** @type {(Node|NodeGroup|Expr)[]} */
69
+ let newNodes = [];
70
+ let oldNodeGroups = path.nodeGroups;
71
+ /*#IFDEV*/assert(!oldNodeGroups.includes(null))/*#ENDIF*/
72
+ let secondPass = []; // indices
73
+
74
+ path.nodeGroups = []; // Reset before applyExactNodes and the code below rebuilds it.
75
+ path.applyExactNodes(expr, newNodes, secondPass);
76
+
77
+ //this.existingTextNodes = null;
78
+
79
+ // TODO: Create an array of old vs Nodes and NodeGroups together.
80
+ // If they're all the same, skip the next steps.
81
+ // Or calculate it in the loop above as we go? Have a path.lastNodeGroups property?
82
+
83
+ // Second pass to find close-match NodeGroups.
84
+ let flatten = false;
85
+ if (secondPass.length) {
86
+ for (let [nodesIndex, ngIndex] of secondPass) {
87
+ let ng = path.getNodeGroup(newNodes[nodesIndex], false);
88
+ let ngNodes = ng.getNodes();
89
+
90
+ /*#IFDEV*/assert(!(newNodes[nodesIndex] instanceof NodeGroup))/*#ENDIF*/
91
+
92
+ if (ngNodes.length === 1) // flatten manually so we can skip flattening below.
93
+ newNodes[nodesIndex] = ngNodes[0];
94
+
95
+ else {
96
+ newNodes[nodesIndex] = ngNodes;
97
+ flatten = true;
98
+ }
99
+ path.nodeGroups[ngIndex] = ng;
100
+ }
101
+
102
+ if (flatten)
103
+ newNodes = newNodes.flat(); // Only if second pass happens.
104
+ }
105
+
106
+ /*#IFDEV*/assert(!path.nodeGroups.includes(null))/*#ENDIF*/
107
+
108
+ let oldNodes = path.getNodes();
109
+
110
+ // This pre-check makes it a few percent faster?
111
+ let same = Util.arraySame(oldNodes, newNodes);
112
+ if (!same) {
113
+
114
+ path.nodesCache = newNodes; // Replaces value set by path.getNodes()
115
+
116
+ if (this.parentNg.parentPath)
117
+ this.parentNg.parentPath.clearNodesCache();
118
+
119
+ // Fast clear method
120
+ let isNowEmpty = oldNodes.length && !newNodes.length;
121
+ if (!isNowEmpty || !path.fastClear()) {
122
+
123
+ // Rearrange nodes.
124
+ udomdiff(path.nodeMarker.parentNode, oldNodes, newNodes, path.nodeMarker)
125
+ }
126
+
127
+ // TODO: Put this in a remove() function of NodeGroup.
128
+ // Then only run it on the old nodeGroups that were actually removed.
129
+ //Util.saveOrphans(oldNodeGroups, oldNodes);
130
+
131
+ for (let ng of oldNodeGroups)
132
+ if (!ng.startNode.parentNode)
133
+ Util.saveOrphans(ng.getNodes());
134
+ }
135
+
136
+ /*#IFDEV*/path.verify();/*#ENDIF*/
137
+ }
138
+
139
+
140
+
141
+
142
+ /**
143
+ * Try to apply Nodes that are an exact match, by finding existing nodes from the last render
144
+ * that have the same value as created by the expr.
145
+ * This is called from Path.applyNodes().
146
+ *
147
+ * @param expr {Template|Node|Array|function|*}
148
+ * @param newNodes {(Node|Template)[]} An inout parameter; we add the nodes here as we go.
149
+ * @param secondPass {[int, int][]} Locations within newNodes for Path.applyNodes() to evaluate later,
150
+ * when it tries to find partial matches. */
151
+ applyExactNodes(expr, newNodes, secondPass) {
152
+
153
+ if (expr instanceof Template) {
154
+ let ng = this.getNodeGroup(expr, true);
155
+
156
+ if (ng) {
157
+ let newestNodes = ng.getNodes();
158
+ newNodes.push(...newestNodes);
159
+
160
+ // New!
161
+ // Call render() on web components even though none of their arguments have changed:
162
+ // Do we want it to work this way? Yes, because even if this component hasn't changed,
163
+ // perhaps something in a sub-component has.
164
+ ng.applyExprs(expr.exprs, false, false);
165
+
166
+ this.nodeGroups.push(ng);
167
+ return ng;
168
+ }
169
+
170
+ // If expression, mark it to be evaluated later in Path.apply() to find partial match.
171
+ else {
172
+ secondPass.push([newNodes.length, this.nodeGroups.length])
173
+ newNodes.push(expr)
174
+ this.nodeGroups.push(null); // placeholder
175
+ }
176
+ }
177
+ else if (expr instanceof NodeList) {
178
+ newNodes.push(...expr);
179
+ }
180
+
181
+ // Node(s) created by an expression.
182
+ else if (expr?.nodeType) {
183
+
184
+ // DocumentFragment created by an expression.
185
+ if (expr?.nodeType === 11) // DocumentFragment
186
+ newNodes.push(...expr.childNodes);
187
+ else
188
+ newNodes.push(expr);
189
+ }
190
+
191
+ // Arrays and functions.
192
+ // I tried iterating over the result of a generator function to avoid this recursion and simplify the code,
193
+ // but that consistently made the js-framework-benchmarks a few percentage points slower.
194
+ else
195
+ this.exprToTemplates(expr, template => {
196
+ this.applyExactNodes(template, newNodes, secondPass);
197
+ })
198
+ }
199
+
200
+ /**
201
+ * Used by watch() for inserting/removing/replacing individual loop items.
202
+ * @param op {ArraySpliceOp} */
203
+ applyWatchArrayOp(op) {
204
+
205
+ // Replace NodeGroups
206
+ let replaceCount = Math.min(op.deleteCount, op.items.length);
207
+ let deleteCount = op.deleteCount - replaceCount;
208
+ for (let i=0; i<replaceCount; i++) {
209
+ let oldNg = this.nodeGroups[op.index + i]; // TODO: One expr can create multiple nodegroups.
210
+
211
+ // Try to find an exact match
212
+ let func = this.mapCallback || this.watchFunction;
213
+ let expr = func(op.items[i]);
214
+
215
+ // If the result of func isn't a template, conver it to one or more templates.
216
+ this.exprToTemplates(expr, template => { // TODO: An expr can create multiple NodeGroups. I need a way to group them.
217
+
218
+ let ng = this.getNodeGroup(template, true); // Removes from nodeGroupsAttached and adds to nodeGroupsRendered()
219
+ if (ng && ng === oldNg) {
220
+ // It's an exact match, so replace nothing.
221
+ // TODO: What if the found NodeGroup as at a differnet place?
222
+ } else {
223
+
224
+ // Find a close match or create a new node group
225
+ if (!ng)
226
+ ng = this.getNodeGroup(template, false); // adds back to nodeGroupsRendered()
227
+ this.nodeGroups[op.index + i] = ng; // TODO: Remove old one to nodeGroupsDetached?
228
+
229
+ // Splice in the new nodes.
230
+ let insertBefore = oldNg.startNode;
231
+ for (let node of ng.getNodes())
232
+ insertBefore.parentNode.insertBefore(node, insertBefore);
233
+
234
+ // Remove the old nodes.
235
+ if (ng !== oldNg)
236
+ Util.saveOrphans(oldNg.getNodes());
237
+ }
238
+ });
239
+ }
240
+
241
+ // Delete extra at the end.
242
+ if (deleteCount > 0) {
243
+ for (let i=0; i<deleteCount; i++) {
244
+ let oldNg = this.nodeGroups[op.index + replaceCount + i];
245
+ Util.saveOrphans(oldNg.getNodes());
246
+ }
247
+ this.nodeGroups.splice(op.index + replaceCount, deleteCount);
248
+ }
249
+
250
+ // Add extra at the end.
251
+ else {
252
+ let newItems = op.items.slice(replaceCount);
253
+
254
+ let insertBefore = this.nodeGroups[op.index + replaceCount]?.startNode || this.nodeMarker;
255
+ for (let i = 0; i < newItems.length; i++) { // We use nodeMarker if the subequent (or all) nodeGroups have been removed.
256
+
257
+
258
+ // Try to find exact match
259
+ let template = this.mapCallback(newItems[i]);
260
+ let ng = this.getNodeGroup(template, true); // Removes from nodeGroupsAttached and adds to nodeGroupsRendered()
261
+ if (!ng) // Find a close match or create a new node group
262
+ ng = this.getNodeGroup(template, false); // adds back to nodeGroupsRendered()
263
+
264
+ this.nodeGroups.push(ng);
265
+
266
+ // Splice in the new nodes.
267
+ for (let node of ng.getNodes())
268
+ insertBefore.parentNode.insertBefore(node, insertBefore);
269
+ }
270
+ }
271
+
272
+ //#IFDEV
273
+ assert(this.nodeGroups.length === op.array.length);
274
+ //#ENDIF
275
+
276
+ // TODO: update or invalidate the nodes cache?
277
+ this.nodesCache = null;
278
+ }
279
+
280
+ /**
281
+ * Clear the nodeCache of this Path, as well as all parent and child Paths that
282
+ * share the same DOM parent node. */
283
+ clearNodesCache() {
284
+ let path = this;
285
+
286
+ // Clear cache parent Paths that have the same parentNode
287
+ let parentNode = this.nodeMarker.parentNode;
288
+ while (path && path.nodeMarker.parentNode === parentNode) {
289
+ path.nodesCache = null;
290
+ path = path.parentNg?.parentPath
291
+
292
+ // If stuck in an infinite loop here, the problem is likely due to Template hash colisions.
293
+ // Which cause one path to be the descendant of itself, creating a cycle.
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Attempt to remove all of this Path's nodes from the DOM, if it can be done using a special fast method.
299
+ * @returns {boolean} Returns false if Nodes weren't removed, and they should instead be removed manually. */
300
+ fastClear() {
301
+ let parent = this.nodeBefore.parentNode;
302
+ if (this.nodeBefore === parent.firstChild && this.nodeMarker === parent.lastChild) {
303
+
304
+ // If parent is the only child of the grandparent, replace the whole parent.
305
+ // And if it has no siblings, it's not created by a NodeGroup/path.
306
+ // Commented out because this will break any references.
307
+ // And because I don't see much performance difference.
308
+ // let grandparent = parent.parentNode
309
+ // if (grandparent && parent === grandparent.firstChild && parent === grandparent.lastChild && !parent.hasAttribute('id')) {
310
+ // let replacement = document.createElement(parent.tagName)
311
+ // replacement.append(this.nodeBefore, this.nodeMarker)
312
+ // for (let attrib of parent.attributes)
313
+ // replacement.setAttribute(attrib.name, attrib.value)
314
+ // parent.replaceWith(replacement)
315
+ // }
316
+ // else {
317
+ parent.innerHTML = ''; // Faster than calling .removeChild() a thousand times.
318
+ parent.append(this.nodeBefore, this.nodeMarker)
319
+ //}
320
+ return true;
321
+ }
322
+ return false;
323
+ }
324
+
325
+ /**
326
+ * Recursively traverse expr.
327
+ * If a value is a function, evaluate it.
328
+ * If a value is an array, recurse on each item.
329
+ * If it's a primitive, convert it to a Template.
330
+ * Otherwise pass the item (which is now either a Template or a Node) to callback.
331
+ * TODO: This could be static if not for the watch code, which doesn't work anyway.
332
+ * @param expr
333
+ * @param callback {function(Node|Template)}*/
334
+ exprToTemplates(expr, callback) {
335
+ if (Array.isArray(expr)) // TODO: use typeof obj[Symbol.iterator] === 'function' so we can also iterate over objects and NodeList?
336
+ for (let subExpr of expr)
337
+ this.exprToTemplates(subExpr, callback);
338
+
339
+ else if (typeof expr === 'function') {
340
+ // TODO: One Path can have multiple expr functions.
341
+ // But if using it as a watch, it should only have one at the top level.
342
+ // So maybe this is ok.
343
+ Globals.currentPath = this; // Used by watch()
344
+
345
+ this.watchFunction = expr; // TODO: Only do this if it's a top level function.
346
+ expr = expr(); // As expr accesses watched variables, watch() uses Globals.currentPath to mark where those watched variables are being used.
347
+ Globals.currentPath = null;
348
+
349
+ this.exprToTemplates(expr, callback);
350
+ }
351
+
352
+ // String/Number/Date/Boolean
353
+ else if (!(expr instanceof Template) && !(expr?.nodeType)){
354
+ // Convert expression to a string.
355
+ if (expr === undefined || expr === false || expr === null) // Util.isFalsy() inlined
356
+ expr = '';
357
+ else if (typeof expr !== 'string')
358
+ expr += '';
359
+
360
+ // Get the same Template for the same string each time.
361
+ // let template = Globals.stringTemplates[expr];
362
+ // if (!template) {
363
+
364
+ let template = new Template([expr], []);
365
+ template.isText = true;
366
+ // Globals.stringTemplates[expr] = template;
367
+ //}
368
+
369
+ // Recurse.
370
+ this.exprToTemplates(template, callback);
371
+ }
372
+ else
373
+ callback(expr);
374
+ }
375
+
376
+ /**
377
+ * Get an unused NodeGroup that matches the template's html and expressions (exact=true)
378
+ * or at least the html (exact=false).
379
+ * Remove it from nodeGroupsFree if it exists, or create it if not.
380
+ * Then add it to nodeGroupsInUse.
381
+ *
382
+ * @param template {Template}
383
+ * @param exact {boolean}
384
+ * If true, return an exact match, or null.
385
+ * If false, either find a match for the template's html and then apply the template's expressions,
386
+ * or createa new NodeGroup from the template.
387
+ * @return {NodeGroup} */
388
+ getNodeGroup(template, exact=true) {
389
+ let result;
390
+ let collection = this.nodeGroupsAttachedAvailable;
391
+
392
+ // TODO: Would it be faster to maintain a separate list of detached nodegroups?
393
+ if (exact) { // [below] parentElement will be null if the parent is a DocumentFragment
394
+ result = collection.deleteAny(template.getExactKey());
395
+ if (!result) { // try searching detached
396
+ collection = this.nodeGroupsDetachedAvailable;
397
+ result = collection.deleteAny(template.getExactKey());
398
+ }
399
+
400
+ if (result) {// also delete the matching close key.
401
+ collection.deleteSpecific(template.getCloseKey(), result);
402
+
403
+ //result.applyExprs(template.exprs);
404
+ }
405
+ else
406
+ return null;
407
+ }
408
+
409
+ // Find a close match.
410
+ // This is a match that has matching html, but different expressions applied.
411
+ // We can then apply the expressions to make it an exact match.
412
+ // 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.
413
+ else if (template.exprs.length) {
414
+ result = collection.deleteAny(template.getCloseKey());
415
+ if (!result) { // try searching detached
416
+ collection = this.nodeGroupsDetachedAvailable;
417
+ result = collection.deleteAny(template.getCloseKey());
418
+ }
419
+
420
+ if (result) {
421
+ /*#IFDEV*/assert(result.exactKey);/*#ENDIF*/
422
+ collection.deleteSpecific(result.exactKey, result);
423
+
424
+ // Update this close match with the new expression values.
425
+ result.applyExprs(template.exprs);
426
+ result.exactKey = template.getExactKey();
427
+ }
428
+ }
429
+
430
+ if (!result) {
431
+ result = new NodeGroup(template, this);
432
+ result.applyExprs(template.exprs);
433
+ result.exactKey = template.getExactKey();
434
+ }
435
+
436
+
437
+ this.nodeGroupsRendered.push(result);
438
+
439
+ /*#IFDEV*/assert(result.parentPath);/*#ENDIF*/
440
+ return result;
441
+ }
442
+
443
+
444
+ /**
445
+ * Move everything from this.nodeGroupsRendered to this.nodeGroupsAttached and nodeGroupsDetached.
446
+ * Called at the beginning of applyNodes() so it can have NodeGroups to use.
447
+ * TODO: this could run as needed in getNodeGroup? */
448
+ freeNodeGroups() {
449
+ // Add nodes that weren't used during render() to nodeGroupsDetached
450
+ let previouslyAttached = this.nodeGroupsAttachedAvailable.data;
451
+ let detached = this.nodeGroupsDetachedAvailable.data;
452
+ for (let key in previouslyAttached) {
453
+ let set = detached[key];
454
+ if (!set)
455
+ detached[key] = previouslyAttached[key]
456
+ else
457
+ for (let ng of previouslyAttached[key])
458
+ set.add(ng);
459
+ }
460
+
461
+ // Add nodes that were used during render() to nodeGroupsRendered.
462
+ this.nodeGroupsAttachedAvailable = new MultiValueMap();
463
+ let nga = this.nodeGroupsAttachedAvailable;
464
+ for (let ng of this.nodeGroupsRendered) {
465
+ nga.add(ng.exactKey, ng);
466
+ nga.add(ng.closeKey, ng);
467
+ }
468
+
469
+ this.nodeGroupsRendered = [];
470
+ }
471
+
472
+
473
+
474
+ /**
475
+ * If not for watch.js, this could be moved to PathToNodes.js
476
+ * @return {(Node|HTMLElement)[]} */
477
+ getNodes() {
478
+
479
+ // Why doesn't this work?
480
+ // let result2 = [];
481
+ // for (let ng of this.nodeGroups)
482
+ // result2.push(...ng.getNodes())
483
+ // return result2;
484
+
485
+ let result
486
+
487
+ // This shaves about 5ms off the partialUpdate benchmark.
488
+ result = this.nodesCache;
489
+ if (result) {
490
+ //#IFDEV
491
+ //this.checkNodesCache();
492
+ //#ENDIF
493
+ return result
494
+ }
495
+
496
+ result = [];
497
+ let current = this.nodeBefore.nextSibling;
498
+ let nodeMarker = this.nodeMarker;
499
+ while (current && current !== nodeMarker) {
500
+ result.push(current)
501
+ current = current.nextSibling
502
+ }
503
+
504
+ this.nodesCache = result;
505
+ return result;
506
+ }
507
+
508
+ //#IFDEV
509
+
510
+ get debug() {
511
+ return [
512
+ `parentNode: ${this.nodeBefore.parentNode?.tagName?.toLowerCase()}`,
513
+ 'nodes:',
514
+ ...setIndent(this.getNodes().map(item => {
515
+ if (item?.nodeType)
516
+ return item.outerHTML || item.textContent
517
+ else if (item instanceof NodeGroup)
518
+ return item.debug
519
+ }), 1).flat()
520
+ ]
521
+ }
522
+
523
+ get debugNodes() {
524
+ // Clear nodesCache so that getNodes() manually gets the nodes.
525
+ let nc = this.nodesCache;
526
+ this.nodesCache = null;
527
+ let result = this.getNodes()
528
+ this.nodesCache = nc;
529
+ return result;
530
+ }
531
+
532
+ checkNodesCache() {
533
+ return;
534
+
535
+ // Make sure cache is accurate.
536
+ // If this is invalid, then perhaps another component append()'d one of our nodes to itself.
537
+ // Or perhaps one of our nodes is used in an expression more than once.
538
+ // TODO: Find a way to check for and warn when this happens.
539
+ // MutationObserver is too slow since it's asynchronous.
540
+ // My own MutationWatcher has to modify DOM prototypes, which is rather invasive.
541
+ if (this.nodesCache) {
542
+ let nodes = [];
543
+ let current = this.nodeBefore.nextSibling;
544
+ let nodeMarker = this.nodeMarker;
545
+ while (current && current !== nodeMarker) {
546
+ nodes.push(current)
547
+ current = current.nextSibling
548
+ }
549
+
550
+ if (!Util.arraySame(this.nodesCache, nodes))
551
+ console.log(this.nodesCache, nodes)
552
+ assert(Util.arraySame(this.nodesCache, nodes) === true);
553
+ }
554
+ }
555
+ //#ENDIF
556
+ }
557
+
558
+
559
+ function walkDOM(el, callback) {
560
+ callback(el);
561
+ let child = el.firstElementChild;
562
+ while (child) {
563
+ walkDOM(child, callback);
564
+ child = child.nextElementSibling;
565
+ }
566
+ }
@@ -0,0 +1,8 @@
1
+ import NodeGroup from './NodeGroup.js';
2
+
3
+ export default class RootNodeGroup extends NodeGroup {
4
+
5
+ // Used only by watch.js
6
+ exprsToRender;
7
+
8
+ }