solarite 0.7.0 → 0.8.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,9 +6,31 @@ import Util from "./Util.js";
6
6
  import Template, {templatesSame, exprSame} from "./Template.js";
7
7
  import Globals from "./Globals.js";
8
8
  import MultiValueMap from "./MultiValueMap.js";
9
+ import MappedList from "./MappedList.js";
10
+ import {SelectorRef} from "./Selector.js";
9
11
 
10
12
  export default class PathToNodes extends Path {
11
13
 
14
+ /** @type {boolean} True once any NodeGroup this path created needs a visit even when its
15
+ * values are unchanged (it holds a component or a live HTML property). Those rows are the
16
+ * reason the list scans exist, so their presence rules out applyMisses()' skip-the-scan
17
+ * path. Sticky: it's never cleared, which can only cost a scan that wasn't needed. */
18
+ anyNeedsRefresh = false;
19
+
20
+ /** @type {?Array} The h.map() items the previous render drew, one per NodeGroup and in the
21
+ * same order, so an unchanged row is recognized by comparing two arrays rather than by
22
+ * following a pointer into each NodeGroup. A thousand rows' NodeGroups are scattered over
23
+ * a hundred kilobytes, so reading a field from each one costs a cache miss apiece; two flat
24
+ * arrays walk in step. Null whenever the last render wasn't an h.map().
25
+ * @type {?Array} */
26
+ lastItems = null;
27
+
28
+ /** @type {boolean} True when the previous render's items contained raw DOM Nodes,
29
+ * which routes applySingle() to the generic reconciler. Declared so the hot
30
+ * `!this.itemsHaveNodes` check reads a real field instead of a missing property,
31
+ * and so the first raw-Node render doesn't transition the hidden class. */
32
+ itemsHaveNodes = false;
33
+
12
34
  /** @type {?NodeGroup[]} The NodeGroups created by this path's expression, in order.
13
35
  * Lazily created; null when the path has only ever rendered a primitive (see textNode). */
14
36
  nodeGroups = null;
@@ -22,14 +44,6 @@ export default class PathToNodes extends Path {
22
44
 
23
45
 
24
46
 
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
47
  /**
34
48
  * Nodes that were added to the web component during the last render(), but are available to be used again.
35
49
  * Used with getNodeGroup() and freeNodeGroups(), keyed by close key.
@@ -47,18 +61,6 @@ export default class PathToNodes extends Path {
47
61
  super(nodeBefore, nodeMarker);
48
62
  }
49
63
 
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
64
  /**
63
65
  * Make the DOM between nodeBefore and nodeMarker match the value of expr.
64
66
  * This is the main entry point for rendering an expression's nodes, chosen from three strategies:
@@ -70,7 +72,7 @@ export default class PathToNodes extends Path {
70
72
  * @param expr {Expr} */
71
73
  applySingle(expr) {
72
74
 
73
- /*#IFDEV*/this.verify();/*#ENDIF*/
75
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
74
76
 
75
77
  // Fast path for a single primitive expression, the most common case in loops.
76
78
  let exprType = typeof expr;
@@ -146,31 +148,452 @@ export default class PathToNodes extends Path {
146
148
  this.textNode = null;
147
149
  }
148
150
 
149
- // 1. Flatten the expression to a list of Templates, strings and Nodes, evaluating functions along the way.
151
+ // A selection binding only knows how to write an attribute, so catch it here rather than
152
+ // letting it render as an empty string and leave the caller wondering where it went.
153
+ if (expr instanceof SelectorRef)
154
+ throw new Error('Solarite: a selector must own the whole attribute.');
155
+
156
+ // 1. h.map() hands over its source items and callback rather than built Templates, so a
157
+ // row whose item is unchanged is recognized without building or looking up a Template.
158
+ if (expr instanceof MappedList) {
159
+ this.applyMapped(expr);
160
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
161
+ return;
162
+ }
163
+
164
+ // Anything that isn't an h.map() leaves no items to recognize rows by next time.
165
+ this.lastItems = null;
166
+
167
+ // 2. Flatten the expression to a list of Templates, strings and Nodes, evaluating functions along the way.
168
+ // A flat array that is entirely Templates — the rows.map(...) shape that list renders
169
+ // produce — is borrowed directly instead of copied. The borrow lasts only for the
170
+ // rest of this synchronous call: applyDiff/applyKeyed/applyGeneric read the items and
171
+ // retain only the NodeGroups (and each item's own Template) built from them, never the
172
+ // items array itself, so no reference to the caller's array survives the render. Keep
173
+ // that invariant — storing newItems on any long-lived object would pin the caller's
174
+ // per-render array until the next render, moving its collection into a later frame.
150
175
  /** @type {(Template|string|Node)[]} */
151
- let newItems = [];
152
- let hasNodesNow = this.collectItems(expr, newItems, false);
176
+ let newItems = null;
177
+ let hasNodesNow = false;
178
+ if (Array.isArray(expr)) {
179
+ let len = expr.length, i = 0;
180
+ while (i < len && expr[i] instanceof Template)
181
+ i++;
182
+ if (i === len)
183
+ newItems = expr; // Borrowed from the caller; read-only from here on.
184
+ }
185
+ if (newItems === null) {
186
+ newItems = [];
187
+ hasNodesNow = this.collectItems(expr, newItems, false);
188
+ }
153
189
 
154
- // 2. Raw Nodes in the items (now or on the previous render) can't be diffed positionally
190
+ // 3. Raw Nodes in the items (now or on the previous render) can't be diffed positionally
155
191
  // because this.nodeGroups only tracks NodeGroups. Use the generic path for those.
156
192
  if (hasNodesNow || this.itemsHaveNodes) {
157
193
  this.itemsHaveNodes = hasNodesNow;
158
194
  this.applyGeneric(newItems);
159
195
  }
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);
196
+ else
197
+ this.diffItems(newItems);
198
+
199
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
200
+ }
201
+
202
+ /**
203
+ * Reconcile a flat list of Templates and strings against this path's NodeGroups.
204
+ * Templates with a key=${} attribute diff by key so node identity follows the data.
205
+ * An empty list also routes to applyKeyed when the previous render was keyed, so removed
206
+ * keyed NodeGroups are discarded instead of pooled.
207
+ * @param newItems {(Template|string)[]} */
208
+ diffItems(newItems) {
209
+ let first = newItems.length !== 0 ? newItems[0] : null;
210
+ if (first !== null
211
+ ? (typeof first !== 'string' && (first.key !== undefined || Shell.get(first.html, first.svgMode).keyIndex >= 0))
212
+ : (this.nodeGroups !== null && this.nodeGroups.length !== 0 && this.nodeGroups[0].key !== undefined))
213
+ this.applyKeyed(newItems);
214
+ else
215
+ this.applyDiff(newItems);
216
+ }
217
+
218
+ /**
219
+ * Render an h.map() list.
220
+ *
221
+ * What makes this cheaper than reconciling an array of Templates is that a row still holding
222
+ * the item it was built from needs no Template at all: it is recognized by one identity
223
+ * check, with nothing built and nothing compared. When the list is the same length and only
224
+ * a few rows changed, that is the whole render — see applyMisses(). Otherwise the walk
225
+ * follows the offset a shifted list settles on, and finally consults a map from item to the
226
+ * Template the previous render built, so rows that moved far are still reused.
227
+ * @param mapped {MappedList} */
228
+ applyMapped(mapped) {
229
+ let items = mapped.items, fn = mapped.fn;
230
+ let len = items.length;
231
+ let oldNgs = this.nodeGroups;
232
+ // Only rows this path drew from an h.map() last time can be recognized by their item;
233
+ // anything else starts over.
234
+ let lastItems = this.lastItems;
235
+ let oldLen = oldNgs === null || lastItems === null || lastItems.length !== oldNgs.length
236
+ ? 0 : oldNgs.length;
237
+
238
+ // Patch path. When the list is the same length as last time, every row that still holds
239
+ // the item it was built from is already final: it needs no Template, no comparison and no
240
+ // visit. So find the positions that did change, build only those, and patch them. That
241
+ // makes a selection or a partial update cost work proportional to the change instead of
242
+ // to the length of the list. Rows that must be visited even when unchanged (components,
243
+ // live HTML properties) rule it out, since revisiting them is what the full scan is for.
244
+ let misses = null, missTemplates = null, missCount = 0;
245
+ if (oldLen === len && len !== 0 && !this.anyNeedsRefresh && !this.itemsHaveNodes) {
246
+ let tooMany = false;
247
+ let cap = missProbeThreshold;
248
+
249
+ // First find WHICH positions changed, without building anything for them. A change
250
+ // this path can't handle is then abandoned having cost only comparisons — building
251
+ // as we went would throw away a Template for every row of, say, a reversed list,
252
+ // which the general diff is about to reuse from the previous render.
253
+ for (let i=0; i<len; i++) {
254
+ if (lastItems[i] !== items[i]) {
255
+ if (missCount === cap) {
256
+ // Enough of the list has changed to ask what kind of change this is,
257
+ // because the two kinds want opposite treatment. If the item at this
258
+ // position is somewhere else in the old list, the rows were reordered,
259
+ // and the general diff's item map will reuse their Templates instead of
260
+ // rebuilding them — so stop here and let it. If the item is new, the
261
+ // rows' contents changed, and there is nothing to reuse: keep going and
262
+ // patch them all, however many there are. The scan costs one pass over
263
+ // the old rows, once, and only for a list that changed this much.
264
+ if (itemIsElsewhere(lastItems, oldLen, items[i])) {
265
+ tooMany = true;
266
+ missCount = 0; // Nothing was built, so the general path has nothing to reuse.
267
+ break;
268
+ }
269
+ cap = len; // Asked and answered; there is no second probe.
270
+ }
271
+ (misses ??= [])[missCount++] = i;
272
+ }
273
+ }
274
+
275
+ // Now build them.
276
+ if (!tooMany && missCount !== 0) {
277
+ missTemplates = new Array(missCount);
278
+ for (let k=0; k<missCount; k++) {
279
+ let t = fn(items[misses[k]]);
280
+ if (!(t instanceof Template) && typeof t !== 'string') { // A Node, an array, …
281
+ tooMany = true;
282
+ missCount = k; // Keep the ones already built; the rest are the caller's problem.
283
+ break;
284
+ }
285
+ missTemplates[k] = t;
286
+ }
287
+ }
288
+ if (!tooMany && (missCount === 0
289
+ || this.applyMisses(oldNgs, misses, missTemplates, missCount, len))) {
290
+ for (let k=0; k<missCount; k++) {
291
+ let j = misses[k];
292
+ lastItems[j] = items[j];
293
+ }
294
+ return;
295
+ }
296
+ }
297
+
298
+ // General path: build the whole list of Templates and hand it to the reconciler.
299
+ let newItems = new Array(len);
300
+ let built = missCount !== 0 ? misses : null, b = 0;
301
+ let itemMap = null, noItemMap = false;
302
+ const indexOfItem = item => {
303
+ if (noItemMap)
304
+ return -1;
305
+ if (itemMap === null) {
306
+ // One scan before paying for a map: if this item is nowhere in the old rows, the
307
+ // list's contents changed rather than moved, so there is nothing to look up and
308
+ // every later miss can go straight to the callback. A scan is cheaper than a map
309
+ // of every row, and this is the common shape — rows replaced in place.
310
+ if (!itemIsElsewhere(lastItems, oldLen, item)) {
311
+ noItemMap = true;
312
+ return -1;
313
+ }
314
+ itemMap = new Map();
315
+ for (let k=0; k<oldLen; k++)
316
+ itemMap.set(lastItems[k], k);
317
+ }
318
+ let k = itemMap.get(item);
319
+ return k === undefined ? -1 : k;
320
+ };
321
+ // Walk the two lists together. A row is recognized by the item it was built from, at the
322
+ // offset the walk has settled on: after an insertion or a removal every later row sits a
323
+ // fixed distance from where it was, and following that keeps recognizing them instead of
324
+ // treating the whole tail as changed. The short search that re-establishes the offset
325
+ // only runs while the walk is still in step, so a list of genuinely new rows (an append,
326
+ // a replace-all) gives up after one miss rather than searching for every row. Failing
327
+ // all that, a map from item to the Template the previous render built for it catches
328
+ // rows that moved far — a sort, a shuffle. It's built on demand, from the rows this
329
+ // path already holds: a persistent per-item cache would instead pay a write for every
330
+ // row of every list ever created, which is most of the work of building a list from
331
+ // scratch, and would hold each Template alive for as long as the caller holds the item.
332
+ if (oldLen !== 0) {
333
+ let delta = 0, inSync = true;
334
+ for (let i=0; i<len; i++) {
335
+ let item = items[i];
336
+ let j = i + delta;
337
+ let inRange = j >= 0 && j < oldLen;
338
+ if (inRange && lastItems[j] === item) {
339
+ newItems[i] = oldNgs[j].template;
340
+ inSync = true;
341
+ continue;
342
+ }
343
+
344
+ // This position was already found to have changed, and its Template built, by the
345
+ // patch scan above. That only happens for a same-length list, where the offset
346
+ // stays zero, so there's no search to redo here.
347
+ if (built !== null && b < missCount && built[b] === i) {
348
+ newItems[i] = missTemplates[b++];
349
+ continue;
350
+ }
351
+
352
+ if (inSync) {
353
+ let found = -1;
354
+ for (let d=1; d<=shiftSearchDistance; d++) {
355
+ let after = j + d, before = j - d;
356
+ if (after < oldLen && lastItems[after] === item) {
357
+ found = after;
358
+ break;
359
+ }
360
+ if (before >= 0 && lastItems[before] === item) {
361
+ found = before;
362
+ break;
363
+ }
364
+ }
365
+ if (found >= 0) {
366
+ delta = found - i;
367
+ newItems[i] = oldNgs[found].template;
368
+ continue;
369
+ }
370
+
371
+ // The item isn't in the old list at all, but the old row standing here
372
+ // belongs to an item a little further along: rows were INSERTED here. Build
373
+ // this one and shift the offset, so the rest of the list is still recognized.
374
+ // Without this, prepending one row to a long list would look like a change to
375
+ // every row in it. Only worth asking when the list actually grew.
376
+ if (inRange && len > oldLen)
377
+ for (let d=1; d<=insertSearchDistance && i+d<len; d++)
378
+ if (items[i+d] === lastItems[j]) {
379
+ newItems[i] = fn(item);
380
+ delta--;
381
+ found = -2; // Handled; skip the fallbacks below.
382
+ break;
383
+ }
384
+ if (found === -2)
385
+ continue;
386
+
387
+ inSync = false;
388
+ }
389
+
390
+ // Past the end of the old list there is nothing left to match, so appended rows
391
+ // go straight to the callback instead of paying for a lookup that must miss.
392
+ if (j < oldLen) {
393
+ let k = indexOfItem(item);
394
+ if (k >= 0) {
395
+ newItems[i] = oldNgs[k].template;
396
+ delta = k - i; // Back in step; the rest of the list can walk positionally again.
397
+ inSync = true;
398
+ continue;
399
+ }
400
+ }
401
+ newItems[i] = fn(item);
402
+ }
403
+ }
404
+
405
+ else
406
+ for (let i=0; i<len; i++)
407
+ newItems[i] = fn(items[i]);
408
+
409
+ // A callback that returns something other than a Template or a string (a raw Node, an
410
+ // array, a nested list) can't be diffed positionally; flatten it the general way.
411
+ let first = len !== 0 ? newItems[0] : null;
412
+ if (first !== null && !(first instanceof Template) && typeof first !== 'string') {
413
+ let flat = [];
414
+ let hasNodesNow = this.collectItems(newItems, flat, false);
415
+ if (hasNodesNow || this.itemsHaveNodes) {
416
+ this.itemsHaveNodes = hasNodesNow;
417
+ this.applyGeneric(flat);
418
+ }
169
419
  else
170
- this.applyDiff(newItems);
420
+ this.diffItems(flat);
421
+ return;
171
422
  }
172
423
 
173
- /*#IFDEV*/this.verify();/*#ENDIF*/
424
+ if (this.itemsHaveNodes) {
425
+ this.itemsHaveNodes = false;
426
+ this.applyGeneric(newItems);
427
+ return;
428
+ }
429
+
430
+ this.diffItems(newItems);
431
+
432
+ // Remember which item drew each row, so the next render can match them by identity.
433
+ // The reconciler leaves nodeGroups aligned with newItems, and therefore with items.
434
+ // The caller's array is copied rather than kept, since the caller mutates it in place.
435
+ let li = this.lastItems;
436
+ if (li === null || li.length !== len)
437
+ li = this.lastItems = new Array(len);
438
+ for (let j=0; j<len; j++)
439
+ li[j] = items[j];
440
+ }
441
+
442
+ /**
443
+ * Patch only the positions an h.map() render changed, leaving every other row alone.
444
+ *
445
+ * Every unchanged position already holds the NodeGroup built from that exact item, so it
446
+ * needs no visit at all; only the changed positions can require a rewrite, a move, or a new
447
+ * row. Changed positions are handled in two steps, the same shape as the general keyed
448
+ * diff's small-reorder path: first the ones that kept their key (a row whose data changed
449
+ * in place), then the leftovers are cross-matched against each other by key so a swap or a
450
+ * short shuffle moves the fewest node ranges.
451
+ *
452
+ * @param ngs {NodeGroup[]} This path's NodeGroups, patched in place.
453
+ * @param misses {int[]} Positions whose item changed, ascending.
454
+ * @param templates {(Template|string)[]} The new Template for each of those positions.
455
+ * @param missCount {int}
456
+ * @param len {int} Length of the list, for anchoring the last position.
457
+ * @return {boolean} False when the change doesn't fit this path and the caller must run
458
+ * the general diff instead; nothing has been modified in that case. */
459
+ applyMisses(ngs, misses, templates, missCount, len) {
460
+
461
+ // Only a keyed list can move rows around safely. An unkeyed one can still be rewritten
462
+ // in place, which is what the positional diff would do for it anyway.
463
+ let keyed = ngs[0].key !== undefined;
464
+
465
+ // 1. Classify the changed positions without touching anything, so that a change too big
466
+ // for this path can still be handed to the general diff with nothing half-applied.
467
+ // A row that kept its key is rewritten where it stands; the rest have to be matched
468
+ // against each other, and past a handful of those the general diff's map-and-LIS
469
+ // approach is the better tool.
470
+ let displaced = null, dCount = 0;
471
+ for (let k=0; k<missCount; k++) {
472
+ let ng = ngs[misses[k]], t = templates[k];
473
+ if (typeof t === 'string' || !itemClose(ng, t) || (keyed && ng.key !== keyOf(t))) {
474
+ if (!keyed || dCount === maxDisplacedMisses)
475
+ return false;
476
+ (displaced ??= [])[dCount++] = k;
477
+ }
478
+ }
479
+
480
+ // 2. Rewrite the rows that kept their key. displaced holds indexes into misses in
481
+ // ascending order, so one pointer walks past them.
482
+ for (let k=0, d=0; k<missCount; k++) {
483
+ if (d < dCount && displaced[d] === k) {
484
+ d++;
485
+ continue;
486
+ }
487
+ let ng = ngs[misses[k]], t = templates[k];
488
+ if (itemSame(ng, t))
489
+ this.refreshSameItem(ng, t);
490
+ else
491
+ this.rewriteNodeGroup(ng, t);
492
+ }
493
+ if (dCount === 0)
494
+ return true;
495
+
496
+ // 3. Hand the displaced rows to the shared placer. displaced holds indexes into misses
497
+ // and templates, so misses is what maps a row to its position in the list.
498
+ let wholeParent = this.wholeParent;
499
+ this.placeDisplaced(displaced, misses, ngs, templates, ngs, len,
500
+ wholeParent ? null : this.nodeMarker,
501
+ wholeParent ? this.nodeMarker : this.nodeMarker.parentNode);
502
+
503
+ // 4. Node membership or order changed, so invalidate caches.
504
+ if (!this.parentNg.firstApply) {
505
+ this.nodesCache = null;
506
+ if (this.parentNg.parentPath)
507
+ this.parentNg.parentPath.clearNodesCache();
508
+ }
509
+
510
+ // Keep state used by the generic path from going stale.
511
+ if (this.nodeGroupsAttachedAvailable)
512
+ this.nodeGroupsAttachedAvailable = null;
513
+ return true;
514
+ }
515
+
516
+ /**
517
+ * Settle a handful of rows that moved, appeared or vanished within one window of a list.
518
+ *
519
+ * Both small-reorder paths — the h.map() patch in applyMisses and the equal-length window in
520
+ * applyKeyed — reach the same point: a few positions whose old NodeGroup no longer belongs
521
+ * where it stands, everything around them already correct. Since every candidate came from
522
+ * this same window, a swap, a dragged row or a short shuffle finds its partners inside it, so
523
+ * the rows are cross-matched against each other by key rather than through the general
524
+ * diff's key map and longest-increasing-subsequence machinery.
525
+ *
526
+ * rows holds ascending indexes into items, which is the array each caller already has; when
527
+ * those indexes are not themselves list positions, positions maps them across. Doing the
528
+ * indirection here rather than compacting it away in the caller keeps this off the allocation
529
+ * path: neither caller builds an array it wasn't building already. rows.length is small by
530
+ * construction (at most maxDisplacedMisses), which is what makes the O(n²) cross-match
531
+ * cheaper than building a map.
532
+ *
533
+ * @param rows {int[]} Ascending indexes of the rows to settle.
534
+ * @param positions {int[]|null} Maps a row index to its list position, or null when the row
535
+ * indexes are already positions.
536
+ * @param oldNgs {NodeGroup[]} Where each position's outgoing NodeGroup is read from.
537
+ * @param items {(Template|string)[]} The new items, indexed by row index.
538
+ * @param outNgs {NodeGroup[]} Receives the NodeGroup that ends up at each position. May be
539
+ * the same array as oldNgs; the outgoing groups are snapshotted before anything is written.
540
+ * @param boundary {int} First position past this window, where the anchor stops being
541
+ * outNgs[p+1] and becomes tailAnchor.
542
+ * @param tailAnchor {Node|null} Anchor for a row placed at boundary-1.
543
+ * @param parent {Node} Where the rows' nodes live. */
544
+ placeDisplaced(rows, positions, oldNgs, items, outNgs, boundary, tailAnchor, parent) {
545
+ let count = rows.length;
546
+
547
+ // 1. Cross-match the rows against each other by key. A claimed NodeGroup is nulled out
548
+ // of the snapshot so it can't be claimed twice.
549
+ let free = new Array(count);
550
+ for (let b=0; b<count; b++) {
551
+ let i = rows[b];
552
+ free[b] = oldNgs[positions === null ? i : positions[i]];
553
+ }
554
+ let placed = new Array(count);
555
+ for (let a=0; a<count; a++) {
556
+ let t = items[rows[a]];
557
+ let key = keyOf(t);
558
+ if (key !== undefined)
559
+ for (let b=0; b<count; b++) {
560
+ let ng = free[b];
561
+ if (ng !== null && ng.key === key && itemClose(ng, t)) {
562
+ free[b] = null;
563
+ if (itemSame(ng, t))
564
+ this.refreshSameItem(ng, t);
565
+ else
566
+ this.rewriteNodeGroup(ng, t);
567
+ placed[a] = ng;
568
+ break;
569
+ }
570
+ }
571
+ }
572
+
573
+ // 2. Discard the old rows nothing claimed. Keyed semantics require a new key to get new
574
+ // nodes, so these are never pooled.
575
+ for (let b=0; b<count; b++) {
576
+ let ng = free[b];
577
+ if (ng !== null) {
578
+ if (ng.startNode !== ng.endNode)
579
+ Util.saveOrphans(ng.getNodes());
580
+ else
581
+ ng.startNode.remove();
582
+ }
583
+ }
584
+
585
+ // 3. Put the rows in place, right to left so each one's anchor is already final.
586
+ for (let a=count-1; a>=0; a--) {
587
+ let i = rows[a];
588
+ let p = positions === null ? i : positions[i];
589
+ let ng = placed[a];
590
+ if (ng === undefined)
591
+ ng = this.createNew(items[i]);
592
+ outNgs[p] = ng;
593
+ let anchor = p+1 < boundary ? outNgs[p+1].startNode : tailAnchor;
594
+ if (ng.endNode.nextSibling !== anchor || ng.startNode.parentNode !== parent)
595
+ insertNodesBefore(parent, ng, anchor);
596
+ }
174
597
  }
175
598
 
176
599
  /**
@@ -193,8 +616,8 @@ export default class PathToNodes extends Path {
193
616
  let ng = oldNgs[start], t = newItems[start];
194
617
  if (!itemSame(ng, t))
195
618
  break;
196
- if (ng.hasComponentPaths)
197
- ng.applyExprs(t.exprs, false);
619
+ if (ng.shell.needsRefresh)
620
+ this.refreshSameItem(ng, t);
198
621
  newNgs[start] = ng;
199
622
  start++;
200
623
  }
@@ -204,8 +627,8 @@ export default class PathToNodes extends Path {
204
627
  let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
205
628
  if (!itemSame(ng, t))
206
629
  break;
207
- if (ng.hasComponentPaths)
208
- ng.applyExprs(t.exprs, false);
630
+ if (ng.shell.needsRefresh)
631
+ this.refreshSameItem(ng, t);
209
632
  newNgs[--newEnd] = ng;
210
633
  oldEnd--;
211
634
  }
@@ -214,8 +637,8 @@ export default class PathToNodes extends Path {
214
637
  while (start < oldEnd && start < newEnd) {
215
638
  let ng = oldNgs[start], t = newItems[start];
216
639
  if (itemSame(ng, t)) { // Can happen between changed rows, e.g. partial updates.
217
- if (ng.hasComponentPaths)
218
- ng.applyExprs(t.exprs, false);
640
+ if (ng.shell.needsRefresh)
641
+ this.refreshSameItem(ng, t);
219
642
  }
220
643
  else if (itemClose(ng, t))
221
644
  this.rewriteNodeGroup(ng, t);
@@ -252,34 +675,18 @@ export default class PathToNodes extends Path {
252
675
  }
253
676
  }
254
677
 
255
- // 5. Insert leftover new items.
678
+ // 5. Insert leftover new items directly. Each row is one native insert; a
679
+ // batching DocumentFragment would double the insert count for no benefit,
680
+ // since style/layout work is deferred until the next frame either way.
256
681
  if (newRemain) {
257
682
  let wholeParent = this.wholeParent;
258
683
  let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
259
684
  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
685
  for (let i=start; i<newEnd; i++) {
268
686
  let ng = this.createOrReuse(newItems[i]);
269
687
  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
- }
688
+ insertNodesBefore(parent, ng, anchor);
280
689
  }
281
- if (fragment)
282
- parent.insertBefore(fragment, anchor);
283
690
  }
284
691
 
285
692
  // 6. Node membership changed, so invalidate caches.
@@ -294,8 +701,6 @@ export default class PathToNodes extends Path {
294
701
  this.nodeGroups = newNgs;
295
702
 
296
703
  // Keep state used by the generic path from going stale.
297
- if (this.nodeGroupsRendered)
298
- this.nodeGroupsRendered = null;
299
704
  if (this.nodeGroupsAttachedAvailable)
300
705
  this.nodeGroupsAttachedAvailable = null;
301
706
  }
@@ -314,23 +719,11 @@ export default class PathToNodes extends Path {
314
719
  let oldLen = oldNgs.length, newLen = newItems.length;
315
720
  let newNgs = new Array(newLen);
316
721
 
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
722
+ //#IFDEBUG
330
723
  {
331
724
  let seen = new Set();
332
725
  for (let t of newItems) {
333
- let k = typeof t === 'string' ? undefined : keyOf(t);
726
+ let k = keyOf(t);
334
727
  if (k === undefined)
335
728
  console.warn('Unkeyed item in a keyed list; it will be rebuilt on every render:', t);
336
729
  else if (seen.has(k))
@@ -348,15 +741,13 @@ export default class PathToNodes extends Path {
348
741
  let ng = oldNgs[start], t = newItems[start];
349
742
  // An identical Template instance (h.map) implies an identical key, so skip key extraction.
350
743
  if (ng.template === t) {
351
- if (ng.hasComponentPaths)
352
- ng.applyExprs(t.exprs, false);
744
+ if (ng.shell.needsRefresh)
745
+ this.refreshSameItem(ng, t);
353
746
  }
354
747
  else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
355
748
  break;
356
- else if (itemSame(ng, t)) {
357
- if (ng.hasComponentPaths)
358
- ng.applyExprs(t.exprs, false);
359
- }
749
+ else if (itemSame(ng, t))
750
+ this.refreshSameItem(ng, t);
360
751
  else
361
752
  this.rewriteNodeGroup(ng, t);
362
753
  newNgs[start] = ng;
@@ -367,15 +758,13 @@ export default class PathToNodes extends Path {
367
758
  while (oldEnd > start && newEnd > start) {
368
759
  let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
369
760
  if (ng.template === t) {
370
- if (ng.hasComponentPaths)
371
- ng.applyExprs(t.exprs, false);
761
+ if (ng.shell.needsRefresh)
762
+ this.refreshSameItem(ng, t);
372
763
  }
373
764
  else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
374
765
  break;
375
- else if (itemSame(ng, t)) {
376
- if (ng.hasComponentPaths)
377
- ng.applyExprs(t.exprs, false);
378
- }
766
+ else if (itemSame(ng, t))
767
+ this.refreshSameItem(ng, t);
379
768
  else
380
769
  this.rewriteNodeGroup(ng, t);
381
770
  newNgs[--newEnd] = ng;
@@ -387,6 +776,57 @@ export default class PathToNodes extends Path {
387
776
  let wholeParent = this.wholeParent;
388
777
  let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
389
778
 
779
+ // 3a. Equal-length windows: scan them aligned. Rows whose keys match positionally
780
+ // are updated in place with no bookkeeping, and when at most 8 positions are
781
+ // displaced (a swap, a dragged row, a small shuffle) they're cross-matched and
782
+ // moved directly — no key map, no sources array, no LIS. A bigger shuffle falls
783
+ // through to the general map phase; the in-place updates already done stay valid
784
+ // there, since the map phase finds those rows already matching their new items.
785
+ let fastHandled = false;
786
+ if (oldRemain === newRemain) {
787
+ let displaced = null;
788
+ let ok = true;
789
+ for (let i=start; i<newEnd; i++) {
790
+ let ng = oldNgs[i], t = newItems[i];
791
+ if (ng.template === t) {
792
+ if (ng.shell.needsRefresh)
793
+ this.refreshSameItem(ng, t);
794
+ }
795
+ else {
796
+ let k = keyOf(t);
797
+ if (k !== undefined && ng.key === k && itemClose(ng, t)) {
798
+ if (itemSame(ng, t))
799
+ this.refreshSameItem(ng, t);
800
+ else
801
+ this.rewriteNodeGroup(ng, t);
802
+ }
803
+ else {
804
+ (displaced ??= []).push(i);
805
+ if (displaced.length > 8) {
806
+ ok = false;
807
+ break;
808
+ }
809
+ continue; // newNgs[i] is filled during the placement pass below.
810
+ }
811
+ }
812
+ newNgs[i] = ng;
813
+ }
814
+ if (ok) {
815
+ // The windows are the same length, so a displaced row's index is already its
816
+ // position and no position map is needed. The tail anchor is the suffix row
817
+ // just past this window, which placement never writes to — it only fills
818
+ // positions below newEnd — so it is computed once here instead of on every
819
+ // pass around the placement loop.
820
+ if (displaced !== null)
821
+ this.placeDisplaced(displaced, null, oldNgs, newItems, newNgs, newEnd,
822
+ newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker),
823
+ parent);
824
+ fastHandled = true;
825
+ }
826
+ }
827
+
828
+ if (!fastHandled) {
829
+
390
830
  // 3. Match the middle windows by key.
391
831
  let kept = 0, moved = false;
392
832
  let sources = null; // sources[i] = old index reused by new item start+i, or -1 to create fresh.
@@ -412,10 +852,8 @@ export default class PathToNodes extends Path {
412
852
  moved = true;
413
853
  else
414
854
  lastNewIndex = newIndex;
415
- if (itemSame(ng, t)) {
416
- if (ng.hasComponentPaths)
417
- ng.applyExprs(t.exprs, false);
418
- }
855
+ if (itemSame(ng, t))
856
+ this.refreshSameItem(ng, t);
419
857
  else
420
858
  this.rewriteNodeGroup(ng, t);
421
859
  newNgs[newIndex] = ng;
@@ -424,59 +862,72 @@ export default class PathToNodes extends Path {
424
862
  (removals ??= []).push(ng);
425
863
  }
426
864
  }
427
- else {
428
- removals = oldNgs.slice(start, oldEnd);
429
- }
865
+ // else: the whole old window goes away. It isn't collected into an array here,
866
+ // because the fast clear below usually takes every one of them at once and the
867
+ // array would be built only to be thrown away.
868
+ }
869
+
870
+ // 3b. A large whole-parent list that is being fully replaced is emptied and refilled
871
+ // with its parent detached, so the browser's connected-tree bookkeeping (child-change
872
+ // notifications, tree-version bumps, MutationObserver interest walks, deferred
873
+ // accessibility and style consumers) runs once at reattach instead of once per row
874
+ // removed and once per row added. Detaching before the clear, rather than after it,
875
+ // puts the removals on the cheap side of that line as well. The gates: the whole
876
+ // region is being replaced, so nothing is kept and no focus can survive inside it;
877
+ // the parent is a plain element, since detaching a custom element would fire its
878
+ // disconnected/connectedCallback in the middle of a render and a subclass may run
879
+ // arbitrary logic there; the parent is in the document, since the notification storm
880
+ // only exists on a connected tree; and the list is long enough for the saving to beat
881
+ // the fixed cost of the detour and the extra MutationObserver records it creates.
882
+ let detachedFrom = null, reattachBefore = null;
883
+ if (wholeParent && start === 0 && newEnd === newLen && kept === 0 && newRemain > 500
884
+ && parent.isConnected && parent.parentNode !== null
885
+ && parent.localName.indexOf('-') === -1 && !parent.hasAttribute('is')) {
886
+ detachedFrom = parent.parentNode;
887
+ reattachBefore = parent.nextSibling;
888
+ parent.remove();
430
889
  }
431
890
 
432
891
  // 4. Remove unmatched old NodeGroups. They're discarded, never pooled,
433
892
  // 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();
893
+ let removeAll = oldRemain !== 0 && newRemain === 0;
894
+ if (removals !== null || removeAll) {
895
+ // Fast clear when nothing is kept anywhere; the whole region is removals. Trying
896
+ // it first means a cleared list skips the two passes below entirely: those exist
897
+ // to lift each group's nodes out one at a time, and emptying the parent has
898
+ // already taken all of them.
899
+ if (!(start === 0 && newEnd === newLen && kept === 0 && this.fastClear())) {
900
+ if (removeAll)
901
+ removals = oldNgs.slice(start, oldEnd);
902
+
903
+ // Materialize node caches of multi-node groups while attached, since detaching breaks sibling links.
904
+ for (let ng of removals)
905
+ if (ng.startNode !== ng.endNode)
906
+ ng.getNodes();
439
907
 
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
908
  for (let ng of removals) {
444
909
  if (ng.startNode !== ng.endNode)
445
910
  Util.saveOrphans(ng.getNodes()); // Moves the nodes out of the DOM, into their own fragment.
446
911
  else
447
912
  ng.startNode.remove();
448
913
  }
914
+ }
449
915
  }
450
916
 
451
917
  // 5. Insert new NodeGroups and move kept ones.
452
918
  if (newRemain) {
453
919
  let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
454
920
 
455
- // 5a. Nothing kept in the middle: batch-insert every new item through a fragment.
921
+ // 5a. Nothing kept in the middle: insert every new item directly.
922
+ // Each row is one native insert; routing rows through a batching
923
+ // DocumentFragment would double the insert count for no benefit, since
924
+ // style/layout work is deferred until the next frame either way.
456
925
  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
926
  for (let i=start; i<newEnd; i++) {
465
927
  let ng = this.createNew(newItems[i]);
466
928
  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
- }
929
+ insertNodesBefore(parent, ng, anchor);
477
930
  }
478
- if (fragment)
479
- parent.insertBefore(fragment, anchor);
480
931
  }
481
932
 
482
933
  // 5b. Mixed: iterate backwards so each item's anchor is already in place.
@@ -503,6 +954,11 @@ export default class PathToNodes extends Path {
503
954
  }
504
955
  }
505
956
 
957
+ if (detachedFrom !== null)
958
+ detachedFrom.insertBefore(parent, reattachBefore);
959
+
960
+ } // end if (!fastHandled)
961
+
506
962
  // 6. Node membership or order changed, so invalidate caches.
507
963
  // During a NodeGroup's first applyExprs(), no ancestor caches can reference its nodes yet.
508
964
  if (!this.parentNg.firstApply) {
@@ -515,8 +971,6 @@ export default class PathToNodes extends Path {
515
971
  this.nodeGroups = newNgs;
516
972
 
517
973
  // Keep state used by the generic path from going stale.
518
- if (this.nodeGroupsRendered)
519
- this.nodeGroupsRendered = null;
520
974
  if (this.nodeGroupsAttachedAvailable)
521
975
  this.nodeGroupsAttachedAvailable = null;
522
976
  }
@@ -530,11 +984,30 @@ export default class PathToNodes extends Path {
530
984
  if (typeof item === 'string')
531
985
  return new NodeGroup(textTemplate(item), this); // Text NodeGroups have no paths to apply.
532
986
  let ng = new NodeGroup(item, this);
987
+ if (ng.shell.needsRefresh)
988
+ this.anyNeedsRefresh = true;
533
989
  if (item.exprs.length || (ng.paths && ng.paths.length))
534
990
  ng.applyExprs(item.exprs);
535
991
  return ng;
536
992
  }
537
993
 
994
+ /**
995
+ * Refresh a NodeGroup whose new template has the SAME values as its current one.
996
+ * Components still render so changes deeper in the tree can surface, and groups holding
997
+ * live-HTML-property bindings (checked/value/selected) rewrite in place — a user's click
998
+ * flips those DOM properties underneath the cached expression, so same values ≠ same DOM.
999
+ * rewriteNodeGroup's per-path skip exempts exactly those paths; everything else is
1000
+ * compared and skipped as before, so this stays cheap.
1001
+ * @param ng {NodeGroup}
1002
+ * @param t {Template|string} */
1003
+ refreshSameItem(ng, t) {
1004
+ let shell = ng.shell;
1005
+ if (shell.hasComponentPaths)
1006
+ ng.applyExprs(t.exprs, false);
1007
+ else if (shell.hasLivePropPaths && shell.pathsSingleExpr && typeof t !== 'string')
1008
+ this.rewriteNodeGroup(ng, t);
1009
+ }
1010
+
538
1011
  /**
539
1012
  * Update an existing NodeGroup, created from the same html strings, with new values.
540
1013
  * @param ng {NodeGroup}
@@ -548,15 +1021,21 @@ export default class PathToNodes extends Path {
548
1021
  else {
549
1022
  // When every path consumes exactly one expression, paths align 1:1 with exprs,
550
1023
  // so only the expressions that changed need to be applied.
551
- if (ng.pathsSingleExpr) {
1024
+ if (ng.shell.pathsSingleExpr) {
552
1025
  // Stamped groups (paths === null) rewrite through the shared stampers and stay
553
1026
  // path-less, unless a child-node expression stopped being primitive.
554
1027
  if (ng.paths !== null || !ng.rewriteStamp(item)) {
555
1028
  let oldExprs = ng.template.exprs, newExprs = item.exprs;
556
1029
  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]);
1030
+ for (let i = paths.length - 1; i >= 0; i--) {
1031
+ // Boolean live-HTML-property bindings are exempt from the unchanged-value
1032
+ // skip — a click flips the property underneath the cached expression;
1033
+ // applySingle() compares against the live node before writing.
1034
+ let oldExpr = oldExprs[i], newExpr = newExprs[i];
1035
+ if ((oldExpr !== newExpr && !exprSame(oldExpr, newExpr))
1036
+ || (paths[i].isHtmlProperty && typeof newExpr === 'boolean'))
1037
+ paths[i].applySingle(newExpr);
1038
+ }
560
1039
  }
561
1040
 
562
1041
  if (ng.styles)
@@ -595,6 +1074,8 @@ export default class PathToNodes extends Path {
595
1074
  }
596
1075
 
597
1076
  ng = new NodeGroup(item, this);
1077
+ if (ng.shell.needsRefresh)
1078
+ this.anyNeedsRefresh = true;
598
1079
  if (item.exprs.length || (ng.paths && ng.paths.length))
599
1080
  ng.applyExprs(item.exprs);
600
1081
  return ng;
@@ -622,6 +1103,14 @@ export default class PathToNodes extends Path {
622
1103
  else if (typeof expr === 'function')
623
1104
  hasNodes = this.collectItems(expr(), items, hasNodes);
624
1105
 
1106
+ // A MappedList nested inside an array or returned from a function can't use the
1107
+ // identity fast path, but it still renders; expand it through the per-item cache.
1108
+ else if (expr instanceof MappedList) {
1109
+ let subItems = expr.items, fn = expr.fn;
1110
+ for (let i=0; i<subItems.length; i++)
1111
+ items.push(fn(subItems[i]));
1112
+ }
1113
+
625
1114
  else if (expr instanceof NodeList) {
626
1115
  for (let node of expr)
627
1116
  items.push(node);
@@ -661,7 +1150,7 @@ export default class PathToNodes extends Path {
661
1150
  /** @type {Node[]} */
662
1151
  let newNodes = [];
663
1152
  let oldNodeGroups = path.nodeGroups || emptyNodeGroups;
664
- /*#IFDEV*/assert(!oldNodeGroups.includes(null))/*#ENDIF*/
1153
+ /*#IFDEBUG*/assert(!oldNodeGroups.includes(null))/*#ENDIF*/
665
1154
 
666
1155
  path.nodeGroups = [];
667
1156
  for (let item of items) {
@@ -769,29 +1258,26 @@ export default class PathToNodes extends Path {
769
1258
  || this.nodeGroupsDetachedAvailable?.deleteAny(closeKey);
770
1259
 
771
1260
  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
- }
1261
+ if (templatesSame(result.template, template))
1262
+ this.refreshSameItem(result, template);
777
1263
  else
778
1264
  result.applyExprs(template.exprs);
779
1265
  result.template = template;
780
1266
  }
781
1267
  else {
782
1268
  result = new NodeGroup(template, this);
1269
+ if (result.shell.needsRefresh)
1270
+ this.anyNeedsRefresh = true;
783
1271
  result.applyExprs(template.exprs);
784
1272
  }
785
1273
 
786
- (this.nodeGroupsRendered ??= []).push(result);
787
-
788
- /*#IFDEV*/assert(result.parentPath);/*#ENDIF*/
1274
+ /*#IFDEBUG*/assert(result.parentPath);/*#ENDIF*/
789
1275
  return result;
790
1276
  }
791
1277
 
792
1278
 
793
1279
  /**
794
- * Move everything from this.nodeGroupsRendered to this.nodeGroupsAttached and nodeGroupsDetached.
1280
+ * Move everything from this.nodeGroups to this.nodeGroupsAttached and nodeGroupsDetached.
795
1281
  * Called at the beginning of applyGeneric() so it can have NodeGroups to use.
796
1282
  * TODO: this could run as needed in getNodeGroup? */
797
1283
  freeNodeGroups() {
@@ -801,7 +1287,7 @@ export default class PathToNodes extends Path {
801
1287
  let detached = (this.nodeGroupsDetachedAvailable ??= new MultiValueMap()).data;
802
1288
  for (let key in previouslyAttached) {
803
1289
  let src = previouslyAttached[key];
804
- let from = src.head || 0; // Skip entries already consumed by deleteAny().
1290
+ let from = src.hd || 0; // Skip entries already consumed by deleteAny().
805
1291
  let array = detached[key];
806
1292
  if (!array) {
807
1293
  array = detached[key] = from ? src.slice(from) : src;
@@ -809,22 +1295,18 @@ export default class PathToNodes extends Path {
809
1295
  array.length = maxPooledPerKey;
810
1296
  }
811
1297
  else
812
- for (let i=from, max=maxPooledPerKey + (array.head || 0); i<src.length && array.length < max; i++)
1298
+ for (let i=from, max=maxPooledPerKey + (array.hd || 0); i<src.length && array.length < max; i++)
813
1299
  array.push(src[i]);
814
1300
  }
815
1301
  }
816
1302
 
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)
1303
+ // Offer the NodeGroups the last render left in place for reuse. Every path that renders
1304
+ // NodeGroups the positional diff, the keyed diff and applyGeneric alike — leaves them in
1305
+ // this.nodeGroups, so that one array is always the set still standing in the DOM.
1306
+ let nga = this.nodeGroupsAttachedAvailable = new MultiValueMap();
1307
+ if (this.nodeGroups)
1308
+ for (let ng of this.nodeGroups)
825
1309
  nga.add(ng.closeKey, ng);
826
-
827
- this.nodeGroupsRendered = null;
828
1310
  }
829
1311
 
830
1312
 
@@ -844,7 +1326,7 @@ export default class PathToNodes extends Path {
844
1326
  // This shaves about 5ms off the partialUpdate benchmark.
845
1327
  result = this.nodesCache;
846
1328
  if (result) {
847
- //#IFDEV
1329
+ //#IFDEBUG
848
1330
  //this.checkNodesCache();
849
1331
  //#ENDIF
850
1332
  return result
@@ -867,7 +1349,7 @@ export default class PathToNodes extends Path {
867
1349
  return result;
868
1350
  }
869
1351
 
870
- //#IFDEV
1352
+ //#IFDEBUG
871
1353
 
872
1354
  get debug() {
873
1355
  return [
@@ -921,12 +1403,55 @@ export default class PathToNodes extends Path {
921
1403
  // Shared empty array for paths whose nodeGroups were never created. Never mutated.
922
1404
  const emptyNodeGroups = [];
923
1405
 
1406
+ // How many changed h.map() positions applyMapped() collects before it stops to work out what
1407
+ // kind of change it is looking at (see the probe in applyMapped). Below this every ordinary
1408
+ // edit — a selection, a partial update — is handled without asking.
1409
+ const missProbeThreshold = 256;
1410
+
1411
+ // How many of those positions may need matching against each other before the general keyed
1412
+ // diff, with its key map and longest-increasing-subsequence, becomes the cheaper tool. The
1413
+ // cross-match here is quadratic, which only pays while the number of moved rows is small.
1414
+ const maxDisplacedMisses = 16;
1415
+
1416
+ // How far applyMapped() looks around a position to pick a shifted list's rows back up. One
1417
+ // insertion or removal moves everything by one, which the first step finds; a handful at once
1418
+ // still lands inside this window, and past it the item map takes over.
1419
+ const shiftSearchDistance = 4;
1420
+
1421
+ // How far ahead it looks to recognize a block of inserted rows, by finding the item that the
1422
+ // old row standing here now belongs to. Wider than the search above because inserting a page
1423
+ // of rows at once is ordinary, and because this search only runs while the walk is still in
1424
+ // step and stops it dead the first time it fails — so its worst case is one pass of this many
1425
+ // comparisons per render, against building a map of every row in the list.
1426
+ const insertSearchDistance = 64;
1427
+
924
1428
  // Most detached NodeGroups kept per close key. Bounds memory growth after very large
925
1429
  // lists are cleared while keeping pooled rows for every typical re-create pattern.
926
1430
  // Lowering this (e.g. to 1000) cuts retained memory ~7x after clearing a 10k-row list,
927
1431
  // but makes re-creating such a list ~2x slower since most rows are built fresh.
928
1432
  const maxPooledPerKey = 10000;
929
1433
 
1434
+
1435
+ // Cache for keyOf(): list rows share one html array, so the Shell lookup that finds where the
1436
+ // key=${} expression sits happens once per list rather than once per row.
1437
+ let lastKeyHtml = null, lastKeyIndex = -1;
1438
+
1439
+ /**
1440
+ * The list key of an item, or undefined when it has none.
1441
+ * @param t {Template|string}
1442
+ * @return {*} */
1443
+ function keyOf(t) {
1444
+ if (typeof t === 'string')
1445
+ return undefined;
1446
+ if (t.key !== undefined) // JSX templates carry the key directly.
1447
+ return t.key;
1448
+ if (t.html !== lastKeyHtml) {
1449
+ lastKeyHtml = t.html;
1450
+ lastKeyIndex = Shell.get(t.html, t.svgMode).keyIndex;
1451
+ }
1452
+ return lastKeyIndex >= 0 ? t.exprs[lastKeyIndex] : undefined;
1453
+ }
1454
+
930
1455
  /**
931
1456
  * @param text {string}
932
1457
  * @return {Template} */
@@ -963,6 +1488,21 @@ function itemClose(ng, item) {
963
1488
  return tpl.html === item.html && tpl.svgMode === item.svgMode;
964
1489
  }
965
1490
 
1491
+ /**
1492
+ * Is this item somewhere in the list the previous render drew, i.e. did it move rather than
1493
+ * appear? A plain scan rather than a map, because it runs once and usually answers on the way
1494
+ * past.
1495
+ * @param lastItems {Array}
1496
+ * @param oldLen {int}
1497
+ * @param item {*}
1498
+ * @return {boolean} */
1499
+ function itemIsElsewhere(lastItems, oldLen, item) {
1500
+ for (let i=0; i<oldLen; i++)
1501
+ if (lastItems[i] === item)
1502
+ return true;
1503
+ return false;
1504
+ }
1505
+
966
1506
  /**
967
1507
  * Insert all of ng's nodes before anchor within parent.
968
1508
  * @param parent {Node}