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.
- package/dist/Solarite-debug.js +5193 -4114
- package/dist/Solarite.js +4951 -3878
- package/dist/Solarite.min.js +2 -2
- package/dist/jsx-dev-runtime.js +3 -0
- package/dist/jsx-runtime.js +86 -0
- package/package.json +11 -10
- package/readme.md +8 -8
- package/src/MappedList.js +34 -0
- package/src/MultiValueMap.js +4 -4
- package/src/NodeGroup.js +205 -119
- package/src/Path.js +52 -53
- package/src/PathToAttribValue.js +182 -101
- package/src/PathToAttribs.js +10 -18
- package/src/PathToComponent.js +43 -19
- package/src/PathToEvent.js +18 -12
- package/src/PathToKey.js +0 -6
- package/src/PathToNodes.js +695 -155
- package/src/RootNodeGroup.js +23 -30
- package/src/Selector.js +207 -0
- package/src/Shell.js +276 -117
- package/src/Solarite.d.ts +89 -13
- package/src/Solarite.js +38 -25
- package/src/Template.js +20 -7
- package/src/Util.js +75 -60
- package/src/assert.js +1 -1
- package/src/assignAttributes.js +20 -19
- package/src/h.js +64 -37
- package/src/jsx.js +4 -7
- package/src/toEl.js +1 -1
- package/src/HtmlParser.js +0 -91
- package/src/PathToComment.js +0 -8
package/src/PathToNodes.js
CHANGED
|
@@ -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
|
-
/*#
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
420
|
+
this.diffItems(flat);
|
|
421
|
+
return;
|
|
171
422
|
}
|
|
172
423
|
|
|
173
|
-
|
|
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.
|
|
197
|
-
|
|
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.
|
|
208
|
-
|
|
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.
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
352
|
-
|
|
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
|
-
|
|
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.
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
/*#
|
|
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
|
-
|
|
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
|
-
(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
818
|
-
//
|
|
819
|
-
// this.nodeGroups
|
|
820
|
-
this.nodeGroupsAttachedAvailable = new MultiValueMap();
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
//#
|
|
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
|
-
//#
|
|
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}
|