jq79 0.6.0 → 0.6.2
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/jq79.cjs +13 -13
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.global.js +13 -13
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +13 -13
- package/dist/jq79.js.map +1 -1
- package/package.json +2 -1
- package/src/jq79.ts +242 -48
- package/src/reactive.ts +170 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jq79",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"reactive",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"site": "node scripts/build-site.mjs",
|
|
66
66
|
"site.dev": "node scripts/site-dev.mjs",
|
|
67
67
|
"benchmark": "node scripts/run-benchmark.mjs",
|
|
68
|
+
"benchmark:ab": "node scripts/run-ab.mjs",
|
|
68
69
|
"benchmark:comparison": "node scripts/run-comparison.mjs",
|
|
69
70
|
"prepublishOnly": "npm test && npm run build"
|
|
70
71
|
},
|
package/src/jq79.ts
CHANGED
|
@@ -267,8 +267,49 @@ const evalHandler = (expr: string, scope: Record<string, any>, extras: Record<st
|
|
|
267
267
|
|
|
268
268
|
// [\s\S] rather than `.` so an expression can span lines, like the ones in
|
|
269
269
|
// directive attributes (which reach evalExpr wrapped in parens either way)
|
|
270
|
-
const
|
|
271
|
-
|
|
270
|
+
const INTERPOLATION_RE = /{{\s*([\s\S]+?)\s*}}/g
|
|
271
|
+
|
|
272
|
+
// A text template split once into its literal and expression parts. The split
|
|
273
|
+
// used to happen on every run of every instance - `String.replace` over the
|
|
274
|
+
// whole text, a fresh match object and a callback per expression - and a
|
|
275
|
+
// :each over 1,000 rows runs it 1,000 times per text node to reach the same
|
|
276
|
+
// answer about the same string. Keyed by the template text, like compileExpr's
|
|
277
|
+
// cache and bounded the same way: by how many distinct texts the source holds
|
|
278
|
+
//
|
|
279
|
+
// An expression part is boxed so a literal `"x"` and an expression `x` stay
|
|
280
|
+
// distinguishable without a second array
|
|
281
|
+
type TextPart = string | { expr: string }
|
|
282
|
+
|
|
283
|
+
const textParts = new Map<string, TextPart[]>()
|
|
284
|
+
|
|
285
|
+
const splitText = (template: string): TextPart[] => {
|
|
286
|
+
const cached = textParts.get(template)
|
|
287
|
+
if (cached) return cached
|
|
288
|
+
const parts: TextPart[] = []
|
|
289
|
+
let at = 0
|
|
290
|
+
INTERPOLATION_RE.lastIndex = 0
|
|
291
|
+
for (let match = INTERPOLATION_RE.exec(template); match; match = INTERPOLATION_RE.exec(template)) {
|
|
292
|
+
if (match.index > at) parts.push(template.slice(at, match.index))
|
|
293
|
+
parts.push({ expr: match[1] })
|
|
294
|
+
at = match.index + match[0].length
|
|
295
|
+
}
|
|
296
|
+
if (at < template.length) parts.push(template.slice(at))
|
|
297
|
+
textParts.set(template, parts)
|
|
298
|
+
return parts
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// what an interpolated text node renders to, from the parts. `?? ""` on each
|
|
302
|
+
// expression, and String() over the join, is what template.replace did: a
|
|
303
|
+
// nullish value contributes nothing and everything else is coerced
|
|
304
|
+
const renderText = (parts: TextPart[], scope: Record<string, any>): string => {
|
|
305
|
+
if (parts.length === 1) {
|
|
306
|
+
const only = parts[0]
|
|
307
|
+
return typeof only === "string" ? only : String(evalExpr(only.expr, scope) ?? "")
|
|
308
|
+
}
|
|
309
|
+
let out = ""
|
|
310
|
+
for (const part of parts) out += typeof part === "string" ? part : String(evalExpr(part.expr, scope) ?? "")
|
|
311
|
+
return out
|
|
312
|
+
}
|
|
272
313
|
|
|
273
314
|
|
|
274
315
|
const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
|
|
@@ -378,16 +419,16 @@ const removeRange = ({ first, last }: NodeRange) => {
|
|
|
378
419
|
// single span: unlinking 10,000 rows one at a time is 40% of that operation,
|
|
379
420
|
// profiled - see TODOS/2026-08-23.batch-range-removal.md. Runs are built by the
|
|
380
421
|
// caller, which is the only place that knows what else is going
|
|
381
|
-
const removeRuns = (runs: NodeRange[]
|
|
422
|
+
const removeRuns = (runs: NodeRange[]) => {
|
|
382
423
|
runs.forEach(run => {
|
|
383
|
-
if (run.
|
|
384
|
-
const parent = run
|
|
424
|
+
if (run.first === run.last) return removeRange(run)
|
|
425
|
+
const parent = run.first.parentNode
|
|
385
426
|
if (!parent) return
|
|
386
427
|
// both ends sit between nodes, so nothing is partially selected and whole
|
|
387
428
|
// nodes are what gets unlinked
|
|
388
429
|
const range = document.createRange()
|
|
389
|
-
range.setStartBefore(run
|
|
390
|
-
range.setEndAfter(run
|
|
430
|
+
range.setStartBefore(run.first)
|
|
431
|
+
range.setEndAfter(run.last)
|
|
391
432
|
range.deleteContents()
|
|
392
433
|
})
|
|
393
434
|
}
|
|
@@ -396,16 +437,19 @@ const removeRuns = (runs: NodeRange[][]) => {
|
|
|
396
437
|
// `ordered` (which is in DOM order). Adjacency is confirmed rather than assumed:
|
|
397
438
|
// a gap - an entry removed earlier in the same pass - starts a new run, so a
|
|
398
439
|
// live entry can never end up inside one
|
|
399
|
-
const contiguousRuns = <T extends { range: NodeRange }>(ordered: T[], isDead: (entry: T) => boolean): NodeRange[]
|
|
400
|
-
const runs: NodeRange[]
|
|
401
|
-
let run: NodeRange
|
|
440
|
+
const contiguousRuns = <T extends { range: NodeRange }>(ordered: T[], isDead: (entry: T) => boolean): NodeRange[] => {
|
|
441
|
+
const runs: NodeRange[] = []
|
|
442
|
+
let run: NodeRange | null = null
|
|
402
443
|
ordered.forEach(entry => {
|
|
403
444
|
if (!isDead(entry)) {
|
|
404
445
|
run = null
|
|
405
446
|
return
|
|
406
447
|
}
|
|
407
|
-
|
|
408
|
-
|
|
448
|
+
// a run is a span, not the list of ranges inside it: only its two ends are
|
|
449
|
+
// ever read, and a list dropping 10,000 rows was building an array of
|
|
450
|
+
// 10,000 entries to hand over two of them
|
|
451
|
+
if (run && run.last.nextSibling === entry.range.first) run.last = entry.range.last
|
|
452
|
+
else runs.push((run = { first: entry.range.first, last: entry.range.last }))
|
|
409
453
|
})
|
|
410
454
|
return runs
|
|
411
455
|
}
|
|
@@ -1218,7 +1262,14 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1218
1262
|
})
|
|
1219
1263
|
}
|
|
1220
1264
|
|
|
1221
|
-
Object.entries(
|
|
1265
|
+
// walked with `for...in` rather than Object.entries().forEach: a 1,000-row
|
|
1266
|
+
// :each renders every element of its template a thousand times, and the
|
|
1267
|
+
// entries form allocates one array of pairs plus one two-element array per
|
|
1268
|
+
// attribute *per instance*. Nothing here reads the pairs as pairs, so the
|
|
1269
|
+
// allocation buys nothing and the garbage it makes is measurable - see
|
|
1270
|
+
// TODOS/2026-08-23.where-the-create-time-goes.md
|
|
1271
|
+
for (const key in node.attrs) {
|
|
1272
|
+
const value = node.attrs[key]
|
|
1222
1273
|
if (key.startsWith("@")) bindEvent(el, key, value, scope)
|
|
1223
1274
|
else if (key === ":model" || key.startsWith(":model.")) {
|
|
1224
1275
|
// :model binds component tags only (see TODOS/2026-07-15.model-directive.md;
|
|
@@ -1249,7 +1300,7 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1249
1300
|
fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
|
|
1250
1301
|
}
|
|
1251
1302
|
} else el.setAttribute(key, value)
|
|
1252
|
-
}
|
|
1303
|
+
}
|
|
1253
1304
|
|
|
1254
1305
|
const bindExpr = node.attrs[":attrs"]
|
|
1255
1306
|
if (bindExpr !== undefined) {
|
|
@@ -1271,17 +1322,23 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1271
1322
|
// static list survives every re-run, even when the expression names one of
|
|
1272
1323
|
// its classes and then drops it (class="btn" :class="{ btn: cond }" keeps
|
|
1273
1324
|
// btn on false)
|
|
1325
|
+
//
|
|
1326
|
+
// The toggle list stays null until a `:class.` attribute is actually found,
|
|
1327
|
+
// for the reason the attribute walk above is a `for...in`: entries + filter +
|
|
1328
|
+
// map allocated three arrays for every element rendered, and the
|
|
1329
|
+
// overwhelming majority of elements carry no `:class.` at all
|
|
1274
1330
|
const classExpr = node.attrs[":class"]
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
.
|
|
1278
|
-
|
|
1331
|
+
let classToggles: [string, string][] | null = null
|
|
1332
|
+
for (const key in node.attrs) {
|
|
1333
|
+
if (key.startsWith(":class.")) (classToggles ??= []).push([key.slice(":class.".length), node.attrs[key]])
|
|
1334
|
+
}
|
|
1335
|
+
if (classExpr !== undefined || classToggles) {
|
|
1279
1336
|
const staticClasses = new Set(classNames(node.attrs.class ?? ""))
|
|
1280
1337
|
let bound: string[] = []
|
|
1281
1338
|
|
|
1282
1339
|
fx.effect(() => {
|
|
1283
1340
|
const next = classExpr !== undefined ? classNames(evalExpr(classExpr, scope)) : []
|
|
1284
|
-
classToggles
|
|
1341
|
+
classToggles?.forEach(([name, expr]) => {
|
|
1285
1342
|
if (evalExpr(expr, scope)) next.push(...classNames(name))
|
|
1286
1343
|
})
|
|
1287
1344
|
bound.forEach(name => {
|
|
@@ -1389,12 +1446,62 @@ const renderConditional = (branches: ConditionalBranch[], scope: Record<string,
|
|
|
1389
1446
|
// reactive proxy's `set` trap: it would wrap `value` as if it were a genuine
|
|
1390
1447
|
// store mutation and fire a bogus notify() under a name (e.g. "item") shared
|
|
1391
1448
|
// by every unrelated item in every :each on the page. defineProperty always
|
|
1392
|
-
// writes to `scope` itself, never delegating, so this can't happen
|
|
1449
|
+
// writes to `scope` itself, never delegating, so this can't happen.
|
|
1450
|
+
//
|
|
1451
|
+
// Only while the key is *missing*, though: once this has run, the property is
|
|
1452
|
+
// own and writable, and a plain assignment finds it there and writes it in
|
|
1453
|
+
// place. That is what renderEach's reuse paths do, and why they may - a
|
|
1454
|
+
// defineProperty costs several times an assignment, and a list that never
|
|
1455
|
+
// reorders would otherwise pay one per row per pass to rewrite the value it
|
|
1456
|
+
// already held
|
|
1393
1457
|
const defineScopeVar = (scope: Record<string, any>, key: string, value: any) => {
|
|
1394
1458
|
Object.defineProperty(scope, key, { value, writable: true, enumerable: true, configurable: true })
|
|
1395
1459
|
}
|
|
1396
1460
|
|
|
1397
|
-
|
|
1461
|
+
// `pos` is where the entry sat in the previous pass, refreshed as the buckets
|
|
1462
|
+
// are built - the positioning pass needs the old order to work out which rows
|
|
1463
|
+
// are already where they belong (see longestIncreasingRun)
|
|
1464
|
+
// `dead` is set by the pass that disposes the entry, and read by the run walk
|
|
1465
|
+
// right after: a Set membership test per row was the alternative, and a list
|
|
1466
|
+
// dropping 10,000 rows does 10,000 of them
|
|
1467
|
+
type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope; pos: number; dead?: boolean }
|
|
1468
|
+
|
|
1469
|
+
// the indices of one longest strictly increasing subsequence of `positions`,
|
|
1470
|
+
// as a flag per index. Fed the old position of every entry in the new order
|
|
1471
|
+
// (-1 for one rendered this pass), it names the rows that are ALREADY in the
|
|
1472
|
+
// right order relative to each other: move everything else and the pass issues
|
|
1473
|
+
// the fewest insertions it can. The walk this serves used to demand only that
|
|
1474
|
+
// each entry follow the one before it, which is minimal for an append and
|
|
1475
|
+
// quadratic for a reorder - one row out of place cascaded into a move for
|
|
1476
|
+
// every row after it, so swapping rows 1 and 998 of a 1,000-row table issued
|
|
1477
|
+
// 997 insertBefore calls where two would do.
|
|
1478
|
+
// Patience sorting, O(n log n): `tails[l]` is the index of the smallest value
|
|
1479
|
+
// that can end an increasing run of length l+1, and `before` remembers what
|
|
1480
|
+
// each index was appended to, which is what makes the run reconstructible
|
|
1481
|
+
const longestIncreasingRun = (positions: number[]): Uint8Array => {
|
|
1482
|
+
const inRun = new Uint8Array(positions.length)
|
|
1483
|
+
const tails: number[] = []
|
|
1484
|
+
const before = new Int32Array(positions.length).fill(-1)
|
|
1485
|
+
|
|
1486
|
+
for (let index = 0; index < positions.length; index++) {
|
|
1487
|
+
const position = positions[index]
|
|
1488
|
+
if (position < 0) continue // rendered this pass: it has no old position to be in order with
|
|
1489
|
+
let low = 0
|
|
1490
|
+
let high = tails.length
|
|
1491
|
+
while (low < high) {
|
|
1492
|
+
const mid = (low + high) >> 1
|
|
1493
|
+
if (positions[tails[mid]] < position) low = mid + 1
|
|
1494
|
+
else high = mid
|
|
1495
|
+
}
|
|
1496
|
+
if (low > 0) before[index] = tails[low - 1]
|
|
1497
|
+
tails[low] = index
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
for (let index = tails.length ? tails[tails.length - 1] : -1; index !== -1; index = before[index]) {
|
|
1501
|
+
inRun[index] = 1
|
|
1502
|
+
}
|
|
1503
|
+
return inRun
|
|
1504
|
+
}
|
|
1398
1505
|
|
|
1399
1506
|
// what :each iterates besides arrays: dictionaries, as their entries. Class
|
|
1400
1507
|
// instances, Maps and the rest stay out - the store doesn't wrap them
|
|
@@ -1436,6 +1543,10 @@ const mentionsAny = (node: TemplateNode | string, names: string[]): boolean => {
|
|
|
1436
1543
|
// as a boundary and would find the `i` of `$index` when looking for `i`
|
|
1437
1544
|
const IDENTIFIER_CHAR = /[A-Za-z0-9_$]/
|
|
1438
1545
|
|
|
1546
|
+
// `a.b` and nothing else: two plain identifiers, one dot. A deeper path could
|
|
1547
|
+
// walk a null halfway down, which is a diagnostic evalExpr owns
|
|
1548
|
+
const KEY_MEMBER = /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/
|
|
1549
|
+
|
|
1439
1550
|
const identifierIn = (text: string, name: string): boolean => {
|
|
1440
1551
|
for (let at = text.indexOf(name); at !== -1; at = text.indexOf(name, at + 1)) {
|
|
1441
1552
|
const before = at === 0 ? "" : text[at - 1]
|
|
@@ -1451,6 +1562,21 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1451
1562
|
|
|
1452
1563
|
const [, itemName, atName, listExpr] = match
|
|
1453
1564
|
const keyExpr = node.attrs[":key"]
|
|
1565
|
+
|
|
1566
|
+
// `:key="row.id"`, or the loop variable itself, is what a key almost always
|
|
1567
|
+
// is - and reading one needs neither a scope to resolve names against nor a
|
|
1568
|
+
// compiled expression, because the item is already in hand. A pass evaluates
|
|
1569
|
+
// one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
|
|
1570
|
+
// the store proxy to discover that nothing had changed: most of the 37% of a
|
|
1571
|
+
// pass that goes on evaluating expressions
|
|
1572
|
+
// (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
|
|
1573
|
+
// an index, a deeper path, a name from the outer scope - still goes through
|
|
1574
|
+
// evalExpr, and so does a non-object item, which keeps every diagnostic a
|
|
1575
|
+
// property read of a null row would have raised
|
|
1576
|
+
const keyIsItem = keyExpr === itemName
|
|
1577
|
+
const keyProp = keyExpr !== undefined && !keyIsItem && KEY_MEMBER.test(keyExpr) && keyExpr.startsWith(`${itemName}.`)
|
|
1578
|
+
? keyExpr.slice(itemName.length + 1)
|
|
1579
|
+
: undefined
|
|
1454
1580
|
const { [":each"]: _each, [":key"]: _key, ...itemAttrs } = node.attrs
|
|
1455
1581
|
const itemNode: TemplateNode = { ...node, attrs: itemAttrs }
|
|
1456
1582
|
|
|
@@ -1486,19 +1612,23 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1486
1612
|
const pass = openRenderPass(scope)
|
|
1487
1613
|
try {
|
|
1488
1614
|
const list = evalExpr(listExpr, scope)
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1615
|
+
// read the source in place rather than normalizing it to [at, item] pairs
|
|
1616
|
+
// first: a list of 1,000 rows built 1,000 two-element arrays per pass,
|
|
1617
|
+
// every one of them garbage by the end of it. `keys` carries a plain
|
|
1618
|
+
// object's property list (insertion order, which is also its identity)
|
|
1619
|
+
// and is null for an array, where the index is the key. Either way the
|
|
1620
|
+
// items are read off the store proxy one at a time, so each stays tracked
|
|
1621
|
+
// under its own key - adds, deletes and changes all wake this effect
|
|
1622
|
+
const array = Array.isArray(list)
|
|
1623
|
+
const keys: string[] | null = array ? null : isPlainObject(list) ? Object.keys(list) : []
|
|
1624
|
+
const length = array ? list.length : keys!.length
|
|
1496
1625
|
// buckets rather than a key->entry map: duplicate keys (a user error, but
|
|
1497
1626
|
// one that must degrade instead of corrupt) consume entries in order of
|
|
1498
1627
|
// appearance, so no entry is ever matched twice - matching one twice is
|
|
1499
1628
|
// how a reused row got disposed and a removed one resurrected
|
|
1500
1629
|
const previous = new Map<any, EachEntry[]>()
|
|
1501
|
-
entries.forEach(entry => {
|
|
1630
|
+
entries.forEach((entry, index) => {
|
|
1631
|
+
entry.pos = index
|
|
1502
1632
|
const bucket = previous.get(entry.key)
|
|
1503
1633
|
if (bucket) bucket.push(entry)
|
|
1504
1634
|
else previous.set(entry.key, [entry])
|
|
@@ -1506,12 +1636,46 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1506
1636
|
|
|
1507
1637
|
const seen = new Set<any>()
|
|
1508
1638
|
const moved: EachEntry[] = []
|
|
1509
|
-
const nextEntries =
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1639
|
+
const nextEntries: EachEntry[] = []
|
|
1640
|
+
// each entry's position in the previous pass, in the new order, for the
|
|
1641
|
+
// positioning walk below - -1 for one rendered here, which has none
|
|
1642
|
+
const positions: number[] = []
|
|
1643
|
+
// one scratch scope for the whole pass, not one per item. A :key
|
|
1644
|
+
// expression reads the item by name (`row.id`), so it needs a scope to
|
|
1645
|
+
// read it from - but which entry that key names, and so whether anything
|
|
1646
|
+
// has to be rendered at all, is only known once it has been evaluated.
|
|
1647
|
+
// Building the entry's real scope up front meant every reused row - the
|
|
1648
|
+
// common case, and nearly all of removeRow, selectRow and swapRows - paid
|
|
1649
|
+
// for an object and three defineProperty calls that were then dropped on
|
|
1650
|
+
// the floor. Nothing outlives the evaluation, which is synchronous, so one
|
|
1651
|
+
// scratch serves the whole list; a row that really is rendered gets a
|
|
1652
|
+
// scope of its own, below
|
|
1653
|
+
let scratch: Record<string, any> | undefined
|
|
1654
|
+
|
|
1655
|
+
for (let index = 0; index < length; index++) {
|
|
1656
|
+
const at = array ? index : keys![index]
|
|
1657
|
+
const item = array ? list[index] : list[at]
|
|
1658
|
+
|
|
1659
|
+
let key: any = at
|
|
1660
|
+
if (keyIsItem) key = item
|
|
1661
|
+
else if (keyProp !== undefined && item !== null && typeof item === "object") key = item[keyProp]
|
|
1662
|
+
else if (keyExpr !== undefined) {
|
|
1663
|
+
if (scratch === undefined) {
|
|
1664
|
+
scratch = Object.create(scope) as Record<string, any>
|
|
1665
|
+
defineScopeVar(scratch, itemName, item)
|
|
1666
|
+
if (atName) defineScopeVar(scratch, atName, at)
|
|
1667
|
+
defineScopeVar(scratch, "$index", index)
|
|
1668
|
+
} else {
|
|
1669
|
+
// own and writable already, so a plain assignment writes to
|
|
1670
|
+
// `scratch` itself - the delegation defineScopeVar exists to
|
|
1671
|
+
// prevent can only happen while the key is missing from it
|
|
1672
|
+
scratch[itemName] = item
|
|
1673
|
+
if (atName) scratch[atName] = at
|
|
1674
|
+
scratch.$index = index
|
|
1675
|
+
}
|
|
1676
|
+
key = evalExpr(keyExpr, scratch)
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1515
1679
|
if (seen.has(key) && !warnedDuplicates) {
|
|
1516
1680
|
warnedDuplicates = true
|
|
1517
1681
|
console.warn(`jq79: duplicate :key in :each "${node.attrs[":each"]}"; duplicates pair up by position`)
|
|
@@ -1520,10 +1684,18 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1520
1684
|
const existing = previous.get(key)?.shift()
|
|
1521
1685
|
|
|
1522
1686
|
if (existing && Object.is(existing.item, item)) {
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1687
|
+
// same reason as `scratch`: these are own writable properties of the
|
|
1688
|
+
// entry's scope from the moment it was rendered, and writing only
|
|
1689
|
+
// what moved keeps a list that never reorders from paying anything
|
|
1690
|
+
const entryScope = existing.scope
|
|
1691
|
+
if (entryScope.$index !== index) {
|
|
1692
|
+
if (readsPosition) moved.push(existing)
|
|
1693
|
+
entryScope.$index = index
|
|
1694
|
+
}
|
|
1695
|
+
if (atName && entryScope[atName] !== at) entryScope[atName] = at
|
|
1696
|
+
nextEntries.push(existing)
|
|
1697
|
+
positions.push(existing.pos)
|
|
1698
|
+
continue
|
|
1527
1699
|
}
|
|
1528
1700
|
|
|
1529
1701
|
if (existing) {
|
|
@@ -1531,12 +1703,17 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1531
1703
|
removeRange(existing.range)
|
|
1532
1704
|
}
|
|
1533
1705
|
|
|
1706
|
+
const itemScope = Object.create(scope)
|
|
1707
|
+
defineScopeVar(itemScope, itemName, item)
|
|
1708
|
+
if (atName) defineScopeVar(itemScope, atName, at)
|
|
1709
|
+
defineScopeVar(itemScope, "$index", index)
|
|
1534
1710
|
const itemFx = createEffectScope(scope)
|
|
1535
1711
|
// bounds captured before the positioning pass inserts the entry: a
|
|
1536
1712
|
// component entry is a fragment, which empties on insertion (see boundsOf)
|
|
1537
1713
|
const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
|
|
1538
|
-
|
|
1539
|
-
|
|
1714
|
+
nextEntries.push({ key, item, scope: itemScope, fx: itemFx, range, pos: index })
|
|
1715
|
+
positions.push(-1)
|
|
1716
|
+
}
|
|
1540
1717
|
|
|
1541
1718
|
// whatever no new item consumed is gone. Effects are torn down one by one
|
|
1542
1719
|
// as always; the DOM goes in runs of neighbours, which is what makes
|
|
@@ -1544,13 +1721,20 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1544
1721
|
const dead = new Set<EachEntry>()
|
|
1545
1722
|
previous.forEach(bucket => bucket.forEach(entry => dead.add(entry)))
|
|
1546
1723
|
if (dead.size) {
|
|
1547
|
-
dead.forEach(entry =>
|
|
1548
|
-
|
|
1724
|
+
dead.forEach(entry => {
|
|
1725
|
+
entry.dead = true
|
|
1726
|
+
entry.fx.dispose()
|
|
1727
|
+
})
|
|
1728
|
+
removeRuns(contiguousRuns(entries, entry => entry.dead === true))
|
|
1549
1729
|
}
|
|
1550
1730
|
|
|
1551
1731
|
let prevNode: Node = anchor
|
|
1552
|
-
|
|
1553
|
-
|
|
1732
|
+
// rows already in order relative to each other stay where they are; the
|
|
1733
|
+
// rest are placed after the row that precedes them in the new order,
|
|
1734
|
+
// which is where the pass has just left `prevNode`
|
|
1735
|
+
const inPlace = longestIncreasingRun(positions)
|
|
1736
|
+
nextEntries.forEach((entry, index) => {
|
|
1737
|
+
if (!inPlace[index] && prevNode.nextSibling !== entry.range.first) moveRangeAfter(entry.range, prevNode)
|
|
1554
1738
|
prevNode = entry.range.last
|
|
1555
1739
|
})
|
|
1556
1740
|
|
|
@@ -1595,7 +1779,17 @@ const renderNodes = <T extends ParentNode>(
|
|
|
1595
1779
|
const textNode = document.createTextNode(node)
|
|
1596
1780
|
// static text is most of a template (all of its indentation, for a start):
|
|
1597
1781
|
// only text with a {{ expression }} in it needs an effect to stay in sync
|
|
1598
|
-
|
|
1782
|
+
// the write is guarded: an effect woken by a sibling's change (one entry
|
|
1783
|
+
// of a :each refreshed on a move, a grouped notification) recomputes the
|
|
1784
|
+
// same string it already wrote, and assigning it back is a DOM mutation
|
|
1785
|
+
// the browser has to take seriously
|
|
1786
|
+
if (node.includes("{{")) {
|
|
1787
|
+
const parts = splitText(node)
|
|
1788
|
+
fx.effect(() => {
|
|
1789
|
+
const text = renderText(parts, scope)
|
|
1790
|
+
if (textNode.textContent !== text) textNode.textContent = text
|
|
1791
|
+
})
|
|
1792
|
+
}
|
|
1599
1793
|
fragment.appendChild(textNode)
|
|
1600
1794
|
i++
|
|
1601
1795
|
continue
|
|
@@ -2929,7 +3123,7 @@ export class Component79 {
|
|
|
2929
3123
|
}
|
|
2930
3124
|
declareProps(store, parseFactoryProps(script.content))
|
|
2931
3125
|
const body = deferred ? defer(factoryCode) : factoryCode
|
|
2932
|
-
return runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
3126
|
+
return runFactoryScript(body, store, run => fx.effect(run), instanceHelpers, $import, at)
|
|
2933
3127
|
}
|
|
2934
3128
|
const { vars, code } = transformSetupScript(script.content)
|
|
2935
3129
|
declareProps(store, setupSignature(script))
|
|
@@ -2937,7 +3131,7 @@ export class Component79 {
|
|
|
2937
3131
|
// to them (and reads of them) through the reactive proxy
|
|
2938
3132
|
vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
|
|
2939
3133
|
const body = deferred ? defer(code) : code
|
|
2940
|
-
return runSetupScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
3134
|
+
return runSetupScript(body, store, run => fx.effect(run), instanceHelpers, $import, at)
|
|
2941
3135
|
})()
|
|
2942
3136
|
// a script that threw has nothing left to contribute, so its rejection
|
|
2943
3137
|
// releases the gate exactly as completion does - the error is already
|