solarite 0.5.2 → 0.7.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/dist/Solarite-debug.js +2823 -1856
  3. package/dist/Solarite.js +2779 -1824
  4. package/dist/Solarite.min.js +2 -4
  5. package/package.json +16 -3
  6. package/readme.md +58 -11
  7. package/src/Globals.js +54 -72
  8. package/src/HtmlParser.js +90 -90
  9. package/src/MultiValueMap.js +57 -105
  10. package/src/NodeGroup.js +624 -470
  11. package/src/Path.js +224 -211
  12. package/src/PathToAttribValue.js +401 -261
  13. package/src/PathToAttribs.js +113 -80
  14. package/src/PathToComment.js +7 -7
  15. package/src/PathToComponent.js +183 -188
  16. package/src/PathToEvent.js +76 -64
  17. package/src/PathToKey.js +19 -0
  18. package/src/PathToNodes.js +1053 -566
  19. package/src/RootNodeGroup.js +120 -8
  20. package/src/Shell.js +570 -348
  21. package/src/Solarite.d.ts +134 -113
  22. package/src/Solarite.js +243 -286
  23. package/src/Template.js +195 -274
  24. package/src/Util.js +353 -351
  25. package/src/assert.js +10 -10
  26. package/src/assignAttributes.js +63 -0
  27. package/src/delve.js +55 -43
  28. package/src/h.js +220 -138
  29. package/src/jsx-dev-runtime.d.ts +1 -0
  30. package/src/jsx-dev-runtime.js +5 -0
  31. package/src/jsx-runtime.d.ts +21 -0
  32. package/src/jsx-runtime.js +84 -0
  33. package/src/jsx.js +194 -0
  34. package/src/toEl.js +77 -82
  35. package/dist/udomdiff-license.txt +0 -18
  36. package/src/getArg.js +0 -137
  37. package/src/hash.js +0 -89
  38. package/src/udomdiff.js +0 -176
  39. package/src/unused/FastLookupArray.js +0 -54
  40. package/src/unused/Hashes.js +0 -339
  41. package/src/unused/InUse.test.js +0 -92
  42. package/src/unused/InUseMap.js +0 -98
  43. package/src/unused/LinkedList.js +0 -117
  44. package/src/unused/LinkedList.test.js +0 -115
  45. package/src/unused/Misc.js +0 -13
  46. package/src/unused/Perf.js +0 -47
  47. package/src/unused/TrackedArray.js +0 -54
  48. package/src/unused/WeakArray.js +0 -33
  49. package/src/watch.js +0 -543
@@ -1,566 +1,1053 @@
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
- }
1
+ import Path from "./Path.js";
2
+ import assert from "./assert.js";
3
+ import NodeGroup from "./NodeGroup.js";
4
+ import Shell from "./Shell.js";
5
+ import Util from "./Util.js";
6
+ import Template, {templatesSame, exprSame} 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
+ /** @type {?NodeGroup[]} The NodeGroups created by this path's expression, in order.
13
+ * Lazily created; null when the path has only ever rendered a primitive (see textNode). */
14
+ nodeGroups = null;
15
+
16
+ /** @type {?Text} When the expression is a single primitive, its text node lives here
17
+ * with no Template or NodeGroup wrapper. Mutually exclusive with nodeGroups entries. */
18
+ textNode = null;
19
+
20
+ /** @type {?string} The current value of textNode. */
21
+ textValue = null;
22
+
23
+
24
+
25
+ /**
26
+ * Nodes that have been used during the current render().
27
+ * Used with getNodeGroup() and freeNodeGroups() on the generic path; the positional diff
28
+ * tracks in-use NodeGroups in this.nodeGroups instead.
29
+ * Lazily created since most paths never use it.
30
+ * @type {?NodeGroup[]} */
31
+ nodeGroupsRendered = null;
32
+
33
+ /**
34
+ * Nodes that were added to the web component during the last render(), but are available to be used again.
35
+ * Used with getNodeGroup() and freeNodeGroups(), keyed by close key.
36
+ * Lazily created since most paths never use it.
37
+ * @type {?MultiValueMap} */
38
+ nodeGroupsAttachedAvailable = null;
39
+
40
+ /**
41
+ * Nodes that were not added to the web component during the last render(), and available to be used again.
42
+ * Lazily created since most paths never use it.
43
+ * @type {?MultiValueMap} */
44
+ nodeGroupsDetachedAvailable = null;
45
+
46
+ constructor(nodeBefore, nodeMarker) {
47
+ super(nodeBefore, nodeMarker);
48
+ }
49
+
50
+ /**
51
+ * Insert/replace the nodes created by a single expression.
52
+ * Called by applyExprs()
53
+ * @param exprs {Expr[]} Only the first is used.
54
+ * @return {Node[]} New Nodes created. */
55
+ apply(exprs) {
56
+ //#IFDEV
57
+ assert(Array.isArray(exprs));
58
+ //#ENDIF
59
+ this.applySingle(exprs[0]);
60
+ }
61
+
62
+ /**
63
+ * Make the DOM between nodeBefore and nodeMarker match the value of expr.
64
+ * This is the main entry point for rendering an expression's nodes, chosen from three strategies:
65
+ * 1. A primitive expr updating (or creating) a single text node is handled inline with no allocations.
66
+ * 2. Otherwise expr is flattened to a list of Templates, strings, and Nodes via collectItems(),
67
+ * then applyDiff() positionally diffs them against the previous render's NodeGroups.
68
+ * 3. If the items contain raw Nodes, now or on the previous render, applyGeneric() uses pooled
69
+ * close-key matching and reconcileNodes(), since this.nodeGroups can't track raw Nodes positionally.
70
+ * @param expr {Expr} */
71
+ applySingle(expr) {
72
+
73
+ /*#IFDEV*/this.verify();/*#ENDIF*/
74
+
75
+ // Fast path for a single primitive expression, the most common case in loops.
76
+ let exprType = typeof expr;
77
+ if ((exprType === 'string' || exprType === 'number') && !this.itemsHaveNodes) {
78
+ if (exprType !== 'string')
79
+ expr += '';
80
+
81
+ // Update the existing text node.
82
+ let tn = this.textNode;
83
+ if (tn !== null) {
84
+ if (this.textValue !== expr) {
85
+ tn.nodeValue = expr;
86
+ this.textValue = expr;
87
+ }
88
+ return;
89
+ }
90
+
91
+ let ngs = this.nodeGroups;
92
+ if (ngs === null || ngs.length === 0) {
93
+
94
+ // Create a bare text node in an empty path, with no Template or NodeGroup wrapper.
95
+ let node;
96
+ if (this.wholeParent) {
97
+ // A NodeGroup re-applied through a shared stamper (NodeGroup.applyStamp/rewriteStamp)
98
+ // can already hold a lone text child; update it in place. Node identity is
99
+ // unchanged then, so no caches need invalidation.
100
+ let fc = this.nodeMarker.firstChild;
101
+ if (fc !== null && fc.nodeType === 3 && fc === this.nodeMarker.lastChild) {
102
+ if (fc.nodeValue !== expr)
103
+ fc.nodeValue = expr;
104
+ this.textNode = fc;
105
+ this.textValue = expr;
106
+ return;
107
+ }
108
+ // One native call; the browser creates the text node.
109
+ this.nodeMarker.textContent = expr;
110
+ node = this.nodeMarker.firstChild;
111
+ }
112
+ else {
113
+ node = Globals.doc.createTextNode(expr);
114
+ this.nodeMarker.parentNode.insertBefore(node, this.nodeMarker);
115
+ }
116
+ this.textNode = node;
117
+ this.textValue = expr;
118
+
119
+ // During a NodeGroup's first applyExprs(), no ancestor caches can reference its nodes yet.
120
+ if (!this.parentNg.firstApply) {
121
+ this.nodesCache = null;
122
+ if (this.parentNg.parentPath)
123
+ this.parentNg.parentPath.clearNodesCache();
124
+ }
125
+ return;
126
+ }
127
+
128
+ // A single text NodeGroup left over from an array render.
129
+ if (ngs.length === 1) {
130
+ let ng = ngs[0], tpl = ng.template;
131
+ if (tpl.isText === true) {
132
+ if (tpl.html[0] !== expr) {
133
+ ng.startNode.nodeValue = expr;
134
+ tpl.html[0] = expr; // Text templates have their own html array, so this can't affect others.
135
+ ng.closeKey = expr;
136
+ }
137
+ return;
138
+ }
139
+ }
140
+ }
141
+
142
+ // A previous primitive render stored a bare text node; wrap it in a NodeGroup so it can be diffed.
143
+ if (this.textNode !== null) {
144
+ let ng = new NodeGroup(textTemplate(this.textValue), this, this.textNode);
145
+ (this.nodeGroups ??= []).push(ng);
146
+ this.textNode = null;
147
+ }
148
+
149
+ // 1. Flatten the expression to a list of Templates, strings and Nodes, evaluating functions along the way.
150
+ /** @type {(Template|string|Node)[]} */
151
+ let newItems = [];
152
+ let hasNodesNow = this.collectItems(expr, newItems, false);
153
+
154
+ // 2. Raw Nodes in the items (now or on the previous render) can't be diffed positionally
155
+ // because this.nodeGroups only tracks NodeGroups. Use the generic path for those.
156
+ if (hasNodesNow || this.itemsHaveNodes) {
157
+ this.itemsHaveNodes = hasNodesNow;
158
+ this.applyGeneric(newItems);
159
+ }
160
+ else {
161
+ // Templates with a key=${} attribute diff by key so node identity follows the data.
162
+ // An empty list also routes to applyKeyed when the previous render was keyed,
163
+ // so removed keyed NodeGroups are discarded instead of pooled.
164
+ let first = newItems.length !== 0 ? newItems[0] : null;
165
+ if (first !== null
166
+ ? (typeof first !== 'string' && (first.key !== undefined || Shell.get(first.html, first.svgMode).keyIndex >= 0))
167
+ : (this.nodeGroups !== null && this.nodeGroups.length !== 0 && this.nodeGroups[0].key !== undefined))
168
+ this.applyKeyed(newItems);
169
+ else
170
+ this.applyDiff(newItems);
171
+ }
172
+
173
+ /*#IFDEV*/this.verify();/*#ENDIF*/
174
+ }
175
+
176
+ /**
177
+ * Positionally diff newItems (all Templates) against this.nodeGroups.
178
+ * Unchanged NodeGroups are kept without any hashing or map lookups.
179
+ * NodeGroups created from the same html are rewritten in place.
180
+ * Leftover items are removed/inserted with direct DOM operations.
181
+ * @param newItems {Template[]} */
182
+ applyDiff(newItems) {
183
+ let oldNgs = this.nodeGroups || emptyNodeGroups;
184
+ let oldLen = oldNgs.length, newLen = newItems.length;
185
+ let newNgs = new Array(newLen);
186
+
187
+ let start = 0;
188
+ let oldEnd = oldLen, newEnd = newLen;
189
+
190
+ // 1. Keep the matching prefix.
191
+ // This runs before the suffix scan so that removing one of several identical items keeps the first ones.
192
+ while (start < oldEnd && start < newEnd) {
193
+ let ng = oldNgs[start], t = newItems[start];
194
+ if (!itemSame(ng, t))
195
+ break;
196
+ if (ng.hasComponentPaths)
197
+ ng.applyExprs(t.exprs, false);
198
+ newNgs[start] = ng;
199
+ start++;
200
+ }
201
+
202
+ // 2. Keep the matching suffix. This makes removing items from the middle cheap.
203
+ while (oldEnd > start && newEnd > start) {
204
+ let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
205
+ if (!itemSame(ng, t))
206
+ break;
207
+ if (ng.hasComponentPaths)
208
+ ng.applyExprs(t.exprs, false);
209
+ newNgs[--newEnd] = ng;
210
+ oldEnd--;
211
+ }
212
+
213
+ // 3. Aligned middle scan: keep unchanged NodeGroups, rewrite same-shape ones in place.
214
+ while (start < oldEnd && start < newEnd) {
215
+ let ng = oldNgs[start], t = newItems[start];
216
+ if (itemSame(ng, t)) { // Can happen between changed rows, e.g. partial updates.
217
+ if (ng.hasComponentPaths)
218
+ ng.applyExprs(t.exprs, false);
219
+ }
220
+ else if (itemClose(ng, t))
221
+ this.rewriteNodeGroup(ng, t);
222
+ else
223
+ break; // Different html at this position. Remove/insert the remaining window below.
224
+ newNgs[start] = ng;
225
+ start++;
226
+ }
227
+
228
+ let oldRemain = oldEnd - start, newRemain = newEnd - start;
229
+ if (oldRemain || newRemain) {
230
+
231
+ // 4. Remove leftover old NodeGroups.
232
+ if (oldRemain) {
233
+ // Materialize node caches of multi-node groups while still attached,
234
+ // since detaching breaks sibling links. Single-node groups don't need it.
235
+ for (let i=start; i<oldEnd; i++) {
236
+ let ng = oldNgs[i];
237
+ if (ng.startNode !== ng.endNode)
238
+ ng.getNodes();
239
+ }
240
+
241
+ // Fast clear when removing everything.
242
+ let cleared = newLen === 0 && start === 0 && this.fastClear();
243
+ let pool = this.nodeGroupsDetachedAvailable ??= new MultiValueMap();
244
+ for (let i=start; i<oldEnd; i++) {
245
+ let ng = oldNgs[i];
246
+ if (ng.startNode !== ng.endNode)
247
+ Util.saveOrphans(ng.getNodes()); // Moves the nodes out of the DOM, into their own fragment.
248
+ else if (!cleared)
249
+ ng.startNode.remove();
250
+ if (!ng.template.isText)
251
+ pool.addCapped(ng.closeKey, ng, maxPooledPerKey);
252
+ }
253
+ }
254
+
255
+ // 5. Insert leftover new items.
256
+ if (newRemain) {
257
+ let wholeParent = this.wholeParent;
258
+ let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
259
+ let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
260
+ let target = parent, before = anchor;
261
+ let fragment = null;
262
+ if (newRemain > 1) { // Batch-insert through a fragment.
263
+ fragment = Globals.doc.createDocumentFragment();
264
+ target = fragment;
265
+ before = null;
266
+ }
267
+ for (let i=start; i<newEnd; i++) {
268
+ let ng = this.createOrReuse(newItems[i]);
269
+ newNgs[i] = ng;
270
+ let node = ng.startNode, end = ng.endNode;
271
+ if (node === end) // Single-node NodeGroups are the common case in loops.
272
+ target.insertBefore(node, before);
273
+ else while (true) {
274
+ let next = node.nextSibling;
275
+ target.insertBefore(node, before);
276
+ if (node === end)
277
+ break;
278
+ node = next;
279
+ }
280
+ }
281
+ if (fragment)
282
+ parent.insertBefore(fragment, anchor);
283
+ }
284
+
285
+ // 6. Node membership changed, so invalidate caches.
286
+ // During a NodeGroup's first applyExprs(), no ancestor caches can reference its nodes yet.
287
+ if (!this.parentNg.firstApply) {
288
+ this.nodesCache = null;
289
+ if (this.parentNg.parentPath)
290
+ this.parentNg.parentPath.clearNodesCache();
291
+ }
292
+ }
293
+
294
+ this.nodeGroups = newNgs;
295
+
296
+ // Keep state used by the generic path from going stale.
297
+ if (this.nodeGroupsRendered)
298
+ this.nodeGroupsRendered = null;
299
+ if (this.nodeGroupsAttachedAvailable)
300
+ this.nodeGroupsAttachedAvailable = null;
301
+ }
302
+
303
+ /**
304
+ * Keyed reconciliation: match this.nodeGroups to newItems by their key=${} expressions,
305
+ * so NodeGroup (and DOM node) identity follows the data:
306
+ * 1. Prefix/suffix scans keep NodeGroups whose keys match in place, rewriting changed content.
307
+ * 2. The middle windows match through a key map, and kept NodeGroups outside a longest
308
+ * increasing subsequence of old positions are moved, so the fewest node ranges move.
309
+ * 3. Unmatched new items create fresh NodeGroups and unmatched old ones are discarded —
310
+ * never pooled so replaced data always gets new nodes, as keyed semantics require.
311
+ * @param newItems {(Template|string)[]} */
312
+ applyKeyed(newItems) {
313
+ let oldNgs = this.nodeGroups || emptyNodeGroups;
314
+ let oldLen = oldNgs.length, newLen = newItems.length;
315
+ let newNgs = new Array(newLen);
316
+
317
+ // Resolve an item's key, caching the html->keyIndex lookup for same-template lists.
318
+ let keyHtml = null, keyIndex = -1;
319
+ const keyOf = t => {
320
+ if (t.key !== undefined) // JSX templates carry the key directly.
321
+ return t.key;
322
+ if (t.html !== keyHtml) {
323
+ keyHtml = t.html;
324
+ keyIndex = Shell.get(t.html, t.svgMode).keyIndex;
325
+ }
326
+ return keyIndex >= 0 ? t.exprs[keyIndex] : undefined;
327
+ };
328
+
329
+ //#IFDEV
330
+ {
331
+ let seen = new Set();
332
+ for (let t of newItems) {
333
+ let k = typeof t === 'string' ? undefined : keyOf(t);
334
+ if (k === undefined)
335
+ console.warn('Unkeyed item in a keyed list; it will be rebuilt on every render:', t);
336
+ else if (seen.has(k))
337
+ console.warn('Duplicate key in keyed list:', k);
338
+ else
339
+ seen.add(k);
340
+ }
341
+ }
342
+ //#ENDIF
343
+
344
+ let start = 0, oldEnd = oldLen, newEnd = newLen;
345
+
346
+ // 1. Keep the matching prefix in place, rewriting changed content.
347
+ while (start < oldEnd && start < newEnd) {
348
+ let ng = oldNgs[start], t = newItems[start];
349
+ // An identical Template instance (h.map) implies an identical key, so skip key extraction.
350
+ if (ng.template === t) {
351
+ if (ng.hasComponentPaths)
352
+ ng.applyExprs(t.exprs, false);
353
+ }
354
+ else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
355
+ break;
356
+ else if (itemSame(ng, t)) {
357
+ if (ng.hasComponentPaths)
358
+ ng.applyExprs(t.exprs, false);
359
+ }
360
+ else
361
+ this.rewriteNodeGroup(ng, t);
362
+ newNgs[start] = ng;
363
+ start++;
364
+ }
365
+
366
+ // 2. Keep the matching suffix.
367
+ while (oldEnd > start && newEnd > start) {
368
+ let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
369
+ if (ng.template === t) {
370
+ if (ng.hasComponentPaths)
371
+ ng.applyExprs(t.exprs, false);
372
+ }
373
+ else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
374
+ break;
375
+ else if (itemSame(ng, t)) {
376
+ if (ng.hasComponentPaths)
377
+ ng.applyExprs(t.exprs, false);
378
+ }
379
+ else
380
+ this.rewriteNodeGroup(ng, t);
381
+ newNgs[--newEnd] = ng;
382
+ oldEnd--;
383
+ }
384
+
385
+ let oldRemain = oldEnd - start, newRemain = newEnd - start;
386
+ if (oldRemain || newRemain) {
387
+ let wholeParent = this.wholeParent;
388
+ let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
389
+
390
+ // 3. Match the middle windows by key.
391
+ let kept = 0, moved = false;
392
+ let sources = null; // sources[i] = old index reused by new item start+i, or -1 to create fresh.
393
+ let removals = null;
394
+ if (oldRemain) {
395
+ if (newRemain) {
396
+ let keyToNewIndex = new Map();
397
+ for (let i=start; i<newEnd; i++) {
398
+ let t = newItems[i];
399
+ if (typeof t !== 'string')
400
+ keyToNewIndex.set(keyOf(t), i);
401
+ }
402
+ sources = new Array(newRemain).fill(-1);
403
+ let lastNewIndex = -1;
404
+ for (let i=start; i<oldEnd; i++) {
405
+ let ng = oldNgs[i];
406
+ let newIndex = ng.key === undefined ? undefined : keyToNewIndex.get(ng.key);
407
+ let t;
408
+ if (newIndex !== undefined && sources[newIndex-start] === -1 && itemClose(ng, t = newItems[newIndex])) {
409
+ sources[newIndex-start] = i;
410
+ kept++;
411
+ if (newIndex < lastNewIndex)
412
+ moved = true;
413
+ else
414
+ lastNewIndex = newIndex;
415
+ if (itemSame(ng, t)) {
416
+ if (ng.hasComponentPaths)
417
+ ng.applyExprs(t.exprs, false);
418
+ }
419
+ else
420
+ this.rewriteNodeGroup(ng, t);
421
+ newNgs[newIndex] = ng;
422
+ }
423
+ else
424
+ (removals ??= []).push(ng);
425
+ }
426
+ }
427
+ else {
428
+ removals = oldNgs.slice(start, oldEnd);
429
+ }
430
+ }
431
+
432
+ // 4. Remove unmatched old NodeGroups. They're discarded, never pooled,
433
+ // so a later render with new keys always creates new nodes.
434
+ if (removals) {
435
+ // Materialize node caches of multi-node groups while attached, since detaching breaks sibling links.
436
+ for (let ng of removals)
437
+ if (ng.startNode !== ng.endNode)
438
+ ng.getNodes();
439
+
440
+ // Fast clear when nothing is kept anywhere; the whole region is removals.
441
+ let cleared = start === 0 && newEnd === newLen && kept === 0 && this.fastClear();
442
+ if (!cleared)
443
+ for (let ng of removals) {
444
+ if (ng.startNode !== ng.endNode)
445
+ Util.saveOrphans(ng.getNodes()); // Moves the nodes out of the DOM, into their own fragment.
446
+ else
447
+ ng.startNode.remove();
448
+ }
449
+ }
450
+
451
+ // 5. Insert new NodeGroups and move kept ones.
452
+ if (newRemain) {
453
+ let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
454
+
455
+ // 5a. Nothing kept in the middle: batch-insert every new item through a fragment.
456
+ if (kept === 0) {
457
+ let target = parent, before = anchor;
458
+ let fragment = null;
459
+ if (newRemain > 1) {
460
+ fragment = Globals.doc.createDocumentFragment();
461
+ target = fragment;
462
+ before = null;
463
+ }
464
+ for (let i=start; i<newEnd; i++) {
465
+ let ng = this.createNew(newItems[i]);
466
+ newNgs[i] = ng;
467
+ let node = ng.startNode, end = ng.endNode;
468
+ if (node === end)
469
+ target.insertBefore(node, before);
470
+ else while (true) {
471
+ let next = node.nextSibling;
472
+ target.insertBefore(node, before);
473
+ if (node === end)
474
+ break;
475
+ node = next;
476
+ }
477
+ }
478
+ if (fragment)
479
+ parent.insertBefore(fragment, anchor);
480
+ }
481
+
482
+ // 5b. Mixed: iterate backwards so each item's anchor is already in place.
483
+ // Kept NodeGroups on a longest increasing subsequence of old positions stay still;
484
+ // everything else moves or is created.
485
+ else {
486
+ let lis = moved ? longestIncreasingSubsequence(sources) : null;
487
+ let lisPos = lis !== null ? lis.length - 1 : -1;
488
+ for (let i=newEnd-1; i>=start; i--) {
489
+ let ng = newNgs[i];
490
+ if (ng === undefined) { // Create and insert.
491
+ ng = this.createNew(newItems[i]);
492
+ newNgs[i] = ng;
493
+ insertNodesBefore(parent, ng, anchor);
494
+ }
495
+ else if (lis !== null) {
496
+ if (lisPos >= 0 && lis[lisPos] === i - start)
497
+ lisPos--; // Part of the stable subsequence; doesn't move.
498
+ else
499
+ insertNodesBefore(parent, ng, anchor);
500
+ }
501
+ anchor = ng.startNode;
502
+ }
503
+ }
504
+ }
505
+
506
+ // 6. Node membership or order changed, so invalidate caches.
507
+ // During a NodeGroup's first applyExprs(), no ancestor caches can reference its nodes yet.
508
+ if (!this.parentNg.firstApply) {
509
+ this.nodesCache = null;
510
+ if (this.parentNg.parentPath)
511
+ this.parentNg.parentPath.clearNodesCache();
512
+ }
513
+ }
514
+
515
+ this.nodeGroups = newNgs;
516
+
517
+ // Keep state used by the generic path from going stale.
518
+ if (this.nodeGroupsRendered)
519
+ this.nodeGroupsRendered = null;
520
+ if (this.nodeGroupsAttachedAvailable)
521
+ this.nodeGroupsAttachedAvailable = null;
522
+ }
523
+
524
+ /**
525
+ * Create a NodeGroup for an item in a keyed list. Never reuses pooled NodeGroups,
526
+ * because keyed semantics require new keys to get new nodes.
527
+ * @param item {Template|string}
528
+ * @return {NodeGroup} */
529
+ createNew(item) {
530
+ if (typeof item === 'string')
531
+ return new NodeGroup(textTemplate(item), this); // Text NodeGroups have no paths to apply.
532
+ let ng = new NodeGroup(item, this);
533
+ if (item.exprs.length || (ng.paths && ng.paths.length))
534
+ ng.applyExprs(item.exprs);
535
+ return ng;
536
+ }
537
+
538
+ /**
539
+ * Update an existing NodeGroup, created from the same html strings, with new values.
540
+ * @param ng {NodeGroup}
541
+ * @param item {Template|string} */
542
+ rewriteNodeGroup(ng, item) {
543
+ if (typeof item === 'string') { // Text content.
544
+ ng.startNode.nodeValue = item;
545
+ ng.template.html[0] = item; // Text templates have their own html array, so this can't affect others.
546
+ ng.closeKey = item;
547
+ }
548
+ else {
549
+ // When every path consumes exactly one expression, paths align 1:1 with exprs,
550
+ // so only the expressions that changed need to be applied.
551
+ if (ng.pathsSingleExpr) {
552
+ // Stamped groups (paths === null) rewrite through the shared stampers and stay
553
+ // path-less, unless a child-node expression stopped being primitive.
554
+ if (ng.paths !== null || !ng.rewriteStamp(item)) {
555
+ let oldExprs = ng.template.exprs, newExprs = item.exprs;
556
+ let paths = ng.paths ?? ng.materializePaths();
557
+ for (let i = paths.length - 1; i >= 0; i--)
558
+ if (!exprSame(oldExprs[i], newExprs[i]))
559
+ paths[i].applySingle(newExprs[i]);
560
+ }
561
+
562
+ if (ng.styles)
563
+ ng.updateStyles();
564
+ ng.nodesCache = null;
565
+ ng.firstApply = false;
566
+ }
567
+ else
568
+ ng.applyExprs(item.exprs);
569
+ ng.template = item;
570
+ if (item.key !== undefined) // JSX keyed item; keep ng.key in sync with the new template.
571
+ ng.key = item.key;
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Create a NodeGroup for an item, reusing a detached one with the same html if available.
577
+ * @param item {Template|string}
578
+ * @return {NodeGroup} */
579
+ createOrReuse(item) {
580
+ let ng;
581
+ if (typeof item === 'string') {
582
+ item = textTemplate(item);
583
+ return new NodeGroup(item, this); // Text NodeGroups have no paths to apply.
584
+ }
585
+
586
+ let pool = this.nodeGroupsDetachedAvailable;
587
+ if (pool) {
588
+ ng = pool.deleteAny(item.getCloseKey());
589
+ if (ng) {
590
+ // rewriteNodeGroup compares expressions and writes only what changed,
591
+ // keeping stamped groups path-less. It also assigns ng.template.
592
+ this.rewriteNodeGroup(ng, item);
593
+ return ng;
594
+ }
595
+ }
596
+
597
+ ng = new NodeGroup(item, this);
598
+ if (item.exprs.length || (ng.paths && ng.paths.length))
599
+ ng.applyExprs(item.exprs);
600
+ return ng;
601
+ }
602
+
603
+ /**
604
+ * Recursively flatten expr into items, evaluating functions and converting primitives to text Templates.
605
+ * @param expr
606
+ * @param items {(Template|Node)[]}
607
+ * @param hasNodes {boolean}
608
+ * @return {boolean} True if any raw Nodes were added to items. */
609
+ collectItems(expr, items, hasNodes) {
610
+ if (expr instanceof Template)
611
+ items.push(expr);
612
+
613
+ else if (Array.isArray(expr)) {
614
+ for (let subExpr of expr) {
615
+ if (subExpr instanceof Template) // Inline the most common case.
616
+ items.push(subExpr);
617
+ else
618
+ hasNodes = this.collectItems(subExpr, items, hasNodes);
619
+ }
620
+ }
621
+
622
+ else if (typeof expr === 'function')
623
+ hasNodes = this.collectItems(expr(), items, hasNodes);
624
+
625
+ else if (expr instanceof NodeList) {
626
+ for (let node of expr)
627
+ items.push(node);
628
+ hasNodes = hasNodes || expr.length > 0;
629
+ }
630
+
631
+ else if (expr?.nodeType) {
632
+ if (expr.nodeType === 11) { // DocumentFragment
633
+ for (let node of [...expr.childNodes])
634
+ items.push(node);
635
+ }
636
+ else
637
+ items.push(expr);
638
+ hasNodes = true;
639
+ }
640
+
641
+ // String/Number/Date/Boolean. Pushed as a plain string to avoid allocating a Template.
642
+ else {
643
+ if (expr === undefined || expr === false || expr === null) // Util.isFalsy() inlined
644
+ expr = '';
645
+ else if (typeof expr !== 'string')
646
+ expr += '';
647
+
648
+ items.push(expr);
649
+ }
650
+ return hasNodes;
651
+ }
652
+
653
+ /**
654
+ * Pool-based reconciliation using close keys and reconcileNodes(). Used when expressions
655
+ * contain raw Nodes, since those can't be tracked by the positional diff.
656
+ * @param items {(Template|string|Node)[]} */
657
+ applyGeneric(items) {
658
+ let path = this;
659
+ path.freeNodeGroups();
660
+
661
+ /** @type {Node[]} */
662
+ let newNodes = [];
663
+ let oldNodeGroups = path.nodeGroups || emptyNodeGroups;
664
+ /*#IFDEV*/assert(!oldNodeGroups.includes(null))/*#ENDIF*/
665
+
666
+ path.nodeGroups = [];
667
+ for (let item of items) {
668
+ if (typeof item === 'string')
669
+ item = textTemplate(item);
670
+ if (item instanceof Template) {
671
+ let ng = path.getNodeGroup(item);
672
+ newNodes.push(...ng.getNodes());
673
+ path.nodeGroups.push(ng);
674
+ }
675
+ else // A raw Node from an expression; collectItems() has already flattened fragments and NodeLists.
676
+ newNodes.push(item);
677
+ }
678
+
679
+ let oldNodes = path.getNodes();
680
+
681
+ // This pre-check makes it a few percent faster?
682
+ let same = Util.arraySame(oldNodes, newNodes);
683
+ if (!same) {
684
+
685
+ path.nodesCache = newNodes; // Replaces value set by path.getNodes()
686
+
687
+ if (this.parentNg.parentPath)
688
+ this.parentNg.parentPath.clearNodesCache();
689
+
690
+ // Fast clear method
691
+ let isNowEmpty = oldNodes.length && !newNodes.length;
692
+ if (!isNowEmpty || !path.fastClear()) {
693
+
694
+ // Rearrange nodes.
695
+ if (path.wholeParent)
696
+ reconcileNodes(path.nodeMarker, oldNodes, newNodes, null)
697
+ else
698
+ reconcileNodes(path.nodeMarker.parentNode, oldNodes, newNodes, path.nodeMarker)
699
+ }
700
+
701
+ // TODO: Put this in a remove() function of NodeGroup.
702
+ // Then only run it on the old nodeGroups that were actually removed.
703
+ //Util.saveOrphans(oldNodeGroups, oldNodes);
704
+
705
+ for (let ng of oldNodeGroups)
706
+ if (!ng.startNode.parentNode)
707
+ Util.saveOrphans(ng.getNodes());
708
+ }
709
+ }
710
+
711
+
712
+ /**
713
+ * Clear the nodeCache of this Path, as well as all parent and child Paths that
714
+ * share the same DOM parent node. */
715
+ clearNodesCache() {
716
+ let path = this;
717
+
718
+ // Clear cache parent Paths that have the same parentNode
719
+ let parentNode = this.wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
720
+ while (path && (path.wholeParent ? path.nodeMarker : path.nodeMarker.parentNode) === parentNode) {
721
+ path.nodesCache = null;
722
+ path = path.parentNg?.parentPath
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Attempt to remove all of this Path's nodes from the DOM, if it can be done using a special fast method.
728
+ * @returns {boolean} Returns false if Nodes weren't removed, and they should instead be removed manually. */
729
+ fastClear() {
730
+ if (this.wholeParent) {
731
+ this.nodeMarker.textContent = '';
732
+ return true;
733
+ }
734
+
735
+ let parent = this.nodeBefore.parentNode;
736
+ if (this.nodeBefore === parent.firstChild && this.nodeMarker === parent.lastChild) {
737
+
738
+ // If parent is the only child of the grandparent, replace the whole parent.
739
+ // And if it has no siblings, it's not created by a NodeGroup/path.
740
+ // Commented out because this will break any references.
741
+ // And because I don't see much performance difference.
742
+ // let grandparent = parent.parentNode
743
+ // if (grandparent && parent === grandparent.firstChild && parent === grandparent.lastChild && !parent.hasAttribute('id')) {
744
+ // let replacement = document.createElement(parent.tagName)
745
+ // replacement.append(this.nodeBefore, this.nodeMarker)
746
+ // for (let attrib of parent.attributes)
747
+ // replacement.setAttribute(attrib.name, attrib.value)
748
+ // parent.replaceWith(replacement)
749
+ // }
750
+ // else {
751
+ parent.innerHTML = ''; // Faster than calling .removeChild() a thousand times.
752
+ parent.append(this.nodeBefore, this.nodeMarker)
753
+ //}
754
+ return true;
755
+ }
756
+ return false;
757
+ }
758
+
759
+ /**
760
+ * Get a NodeGroup with the same html as the template, reusing a pooled one if available.
761
+ * The first pooled NodeGroup with the same close key (html shape) is taken and its
762
+ * expressions are updated, skipping the update when its values are already identical.
763
+ *
764
+ * @param template {Template}
765
+ * @return {NodeGroup} */
766
+ getNodeGroup(template) {
767
+ let closeKey = template.getCloseKey();
768
+ let result = this.nodeGroupsAttachedAvailable?.deleteAny(closeKey)
769
+ || this.nodeGroupsDetachedAvailable?.deleteAny(closeKey);
770
+
771
+ if (result) {
772
+ if (templatesSame(result.template, template)) {
773
+ // Components still render so changes deeper in the tree can surface.
774
+ if (result.hasComponentPaths)
775
+ result.applyExprs(template.exprs, false);
776
+ }
777
+ else
778
+ result.applyExprs(template.exprs);
779
+ result.template = template;
780
+ }
781
+ else {
782
+ result = new NodeGroup(template, this);
783
+ result.applyExprs(template.exprs);
784
+ }
785
+
786
+ (this.nodeGroupsRendered ??= []).push(result);
787
+
788
+ /*#IFDEV*/assert(result.parentPath);/*#ENDIF*/
789
+ return result;
790
+ }
791
+
792
+
793
+ /**
794
+ * Move everything from this.nodeGroupsRendered to this.nodeGroupsAttached and nodeGroupsDetached.
795
+ * Called at the beginning of applyGeneric() so it can have NodeGroups to use.
796
+ * TODO: this could run as needed in getNodeGroup? */
797
+ freeNodeGroups() {
798
+ // Add nodes that weren't used during render() to nodeGroupsDetached
799
+ let previouslyAttached = this.nodeGroupsAttachedAvailable?.data;
800
+ if (previouslyAttached) {
801
+ let detached = (this.nodeGroupsDetachedAvailable ??= new MultiValueMap()).data;
802
+ for (let key in previouslyAttached) {
803
+ let src = previouslyAttached[key];
804
+ let from = src.head || 0; // Skip entries already consumed by deleteAny().
805
+ let array = detached[key];
806
+ if (!array) {
807
+ array = detached[key] = from ? src.slice(from) : src;
808
+ if (array.length > maxPooledPerKey)
809
+ array.length = maxPooledPerKey;
810
+ }
811
+ else
812
+ for (let i=from, max=maxPooledPerKey + (array.head || 0); i<src.length && array.length < max; i++)
813
+ array.push(src[i]);
814
+ }
815
+ }
816
+
817
+ // Add nodes that were used during render() to nodeGroupsRendered.
818
+ // If the last render used the positional diff, the in-use NodeGroups are in
819
+ // this.nodeGroups instead of nodeGroupsRendered.
820
+ this.nodeGroupsAttachedAvailable = new MultiValueMap();
821
+ let nga = this.nodeGroupsAttachedAvailable;
822
+ let source = this.nodeGroupsRendered?.length ? this.nodeGroupsRendered : this.nodeGroups;
823
+ if (source)
824
+ for (let ng of source)
825
+ nga.add(ng.closeKey, ng);
826
+
827
+ this.nodeGroupsRendered = null;
828
+ }
829
+
830
+
831
+
832
+ /**
833
+ * @return {(Node|HTMLElement)[]} */
834
+ getNodes() {
835
+
836
+ // Why doesn't this work?
837
+ // let result2 = [];
838
+ // for (let ng of this.nodeGroups)
839
+ // result2.push(...ng.getNodes())
840
+ // return result2;
841
+
842
+ let result
843
+
844
+ // This shaves about 5ms off the partialUpdate benchmark.
845
+ result = this.nodesCache;
846
+ if (result) {
847
+ //#IFDEV
848
+ //this.checkNodesCache();
849
+ //#ENDIF
850
+ return result
851
+ }
852
+
853
+ result = [];
854
+ let current, stop = null;
855
+ if (this.wholeParent)
856
+ current = this.nodeMarker.firstChild;
857
+ else {
858
+ current = this.nodeBefore.nextSibling;
859
+ stop = this.nodeMarker;
860
+ }
861
+ while (current && current !== stop) {
862
+ result.push(current)
863
+ current = current.nextSibling
864
+ }
865
+
866
+ this.nodesCache = result;
867
+ return result;
868
+ }
869
+
870
+ //#IFDEV
871
+
872
+ get debug() {
873
+ return [
874
+ `parentNode: ${this.nodeBefore.parentNode?.tagName?.toLowerCase()}`,
875
+ 'nodes:',
876
+ ...setIndent(this.getNodes().map(item => {
877
+ if (item?.nodeType)
878
+ return item.outerHTML || item.textContent
879
+ else if (item instanceof NodeGroup)
880
+ return item.debug
881
+ }), 1).flat()
882
+ ]
883
+ }
884
+
885
+ get debugNodes() {
886
+ // Clear nodesCache so that getNodes() manually gets the nodes.
887
+ let nc = this.nodesCache;
888
+ this.nodesCache = null;
889
+ let result = this.getNodes()
890
+ this.nodesCache = nc;
891
+ return result;
892
+ }
893
+
894
+ checkNodesCache() {
895
+ return;
896
+
897
+ // Make sure cache is accurate.
898
+ // If this is invalid, then perhaps another component append()'d one of our nodes to itself.
899
+ // Or perhaps one of our nodes is used in an expression more than once.
900
+ // TODO: Find a way to check for and warn when this happens.
901
+ // MutationObserver is too slow since it's asynchronous.
902
+ // My own MutationWatcher has to modify DOM prototypes, which is rather invasive.
903
+ if (this.nodesCache) {
904
+ let nodes = [];
905
+ let current = this.nodeBefore.nextSibling;
906
+ let nodeMarker = this.nodeMarker;
907
+ while (current && current !== nodeMarker) {
908
+ nodes.push(current)
909
+ current = current.nextSibling
910
+ }
911
+
912
+ if (!Util.arraySame(this.nodesCache, nodes))
913
+ console.log(this.nodesCache, nodes)
914
+ assert(Util.arraySame(this.nodesCache, nodes) === true);
915
+ }
916
+ }
917
+ //#ENDIF
918
+ }
919
+
920
+
921
+ // Shared empty array for paths whose nodeGroups were never created. Never mutated.
922
+ const emptyNodeGroups = [];
923
+
924
+ // Most detached NodeGroups kept per close key. Bounds memory growth after very large
925
+ // lists are cleared while keeping pooled rows for every typical re-create pattern.
926
+ // Lowering this (e.g. to 1000) cuts retained memory ~7x after clearing a 10k-row list,
927
+ // but makes re-creating such a list ~2x slower since most rows are built fresh.
928
+ const maxPooledPerKey = 10000;
929
+
930
+ /**
931
+ * @param text {string}
932
+ * @return {Template} */
933
+ function textTemplate(text) {
934
+ let result = new Template([text], []);
935
+ result.isText = true;
936
+ return result;
937
+ }
938
+
939
+ /**
940
+ * Does the NodeGroup already have content identical to item?
941
+ * @param ng {NodeGroup}
942
+ * @param item {Template|string}
943
+ * @return {boolean} */
944
+ function itemSame(ng, item) {
945
+ let tpl = ng.template;
946
+ if (tpl === item) // h.map() returns the same Template instance for an unchanged item.
947
+ return true;
948
+ if (typeof item === 'string')
949
+ return tpl.isText === true && tpl.html[0] === item;
950
+ return templatesSame(tpl, item);
951
+ }
952
+
953
+ /**
954
+ * Could ng be rewritten in place with the values of item?
955
+ * True when both come from the same html strings (and thus the same Shell), or both are text.
956
+ * @param ng {NodeGroup}
957
+ * @param item {Template|string}
958
+ * @return {boolean} */
959
+ function itemClose(ng, item) {
960
+ let tpl = ng.template;
961
+ if (typeof item === 'string')
962
+ return tpl.isText === true;
963
+ return tpl.html === item.html && tpl.svgMode === item.svgMode;
964
+ }
965
+
966
+ /**
967
+ * Insert all of ng's nodes before anchor within parent.
968
+ * @param parent {Node}
969
+ * @param ng {NodeGroup}
970
+ * @param anchor {?Node} Null appends at the end. */
971
+ function insertNodesBefore(parent, ng, anchor) {
972
+ let node = ng.startNode, end = ng.endNode;
973
+ if (node === end) // Single-node NodeGroups are the common case in loops.
974
+ parent.insertBefore(node, anchor);
975
+ else while (true) {
976
+ let next = node.nextSibling;
977
+ parent.insertBefore(node, anchor);
978
+ if (node === end)
979
+ break;
980
+ node = next;
981
+ }
982
+ }
983
+
984
+ /**
985
+ * Indices into arr whose values form a longest strictly increasing subsequence, skipping -1 entries.
986
+ * O(n log n) patience algorithm with predecessor backtracking, as used by Vue 3's keyed diff.
987
+ * @param arr {int[]}
988
+ * @return {int[]} */
989
+ function longestIncreasingSubsequence(arr) {
990
+ let result = []; // Indices of the smallest known tail for each subsequence length.
991
+ let prev = new Array(arr.length); // prev[i] = index that comes before i in the subsequence ending at i.
992
+ for (let i=0; i<arr.length; i++) {
993
+ let v = arr[i];
994
+ if (v === -1)
995
+ continue;
996
+ // Binary search for the first tail whose value >= v.
997
+ let lo = 0, hi = result.length;
998
+ while (lo < hi) {
999
+ let mid = (lo + hi) >> 1;
1000
+ if (arr[result[mid]] < v)
1001
+ lo = mid + 1;
1002
+ else
1003
+ hi = mid;
1004
+ }
1005
+ if (lo > 0)
1006
+ prev[i] = result[lo-1];
1007
+ if (lo === result.length)
1008
+ result.push(i);
1009
+ else
1010
+ result[lo] = i;
1011
+ }
1012
+ // Backtrack from the last tail to recover the subsequence's indices.
1013
+ let pos = result.length;
1014
+ if (pos) {
1015
+ let i = result[pos-1];
1016
+ while (pos-- > 0) {
1017
+ result[pos] = i;
1018
+ i = prev[i];
1019
+ }
1020
+ }
1021
+ return result;
1022
+ }
1023
+
1024
+
1025
+ /**
1026
+ * Reconcile the children of parentNode so they become newNodes, in order, ending just before
1027
+ * `before` (or at the end when before is null). Reuses existing nodes by identity and skips
1028
+ * nodes already in their target position. Only the raw-Node fallback (applyGeneric) uses this;
1029
+ * the keyed/positional diffs never do.
1030
+ * @param parentNode {Node}
1031
+ * @param oldNodes {Node[]}
1032
+ * @param newNodes {Node[]}
1033
+ * @param before {?Node} */
1034
+ function reconcileNodes(parentNode, oldNodes, newNodes, before) {
1035
+ // 1. Remove old nodes that aren't in the new list.
1036
+ if (oldNodes.length) {
1037
+ let keep = new Set(newNodes);
1038
+ for (let node of oldNodes)
1039
+ if (!keep.has(node) && node.parentNode === parentNode)
1040
+ parentNode.removeChild(node);
1041
+ }
1042
+
1043
+ // 2. Place new nodes in order, walking back to front so `next` is always the already-placed
1044
+ // node that should follow. insertBefore moves a node already in the DOM, so nodes already in
1045
+ // the right spot are skipped to avoid needless mutation.
1046
+ let next = before;
1047
+ for (let i=newNodes.length; i--; ) {
1048
+ let node = newNodes[i];
1049
+ if (node.nextSibling !== next || node.parentNode !== parentNode)
1050
+ parentNode.insertBefore(node, next);
1051
+ next = node;
1052
+ }
1053
+ }