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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.6.0",
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 interpolate = (template: string, scope: Record<string, any>): string =>
271
- template.replace(/{{\s*([\s\S]+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
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.length === 1) return removeRange(run[0])
384
- const parent = run[0].first.parentNode
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[0].first)
390
- range.setEndAfter(run[run.length - 1].last)
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[] | null = null
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
- if (run && run[run.length - 1].last.nextSibling === entry.range.first) run.push(entry.range)
408
- else runs.push((run = [entry.range]))
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(node.attrs).forEach(([key, value]) => {
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
- const classToggles = Object.entries(node.attrs)
1276
- .filter(([key]) => key.startsWith(":class."))
1277
- .map(([key, expr]): [string, string] => [key.slice(":class.".length), expr])
1278
- if (classExpr !== undefined || classToggles.length) {
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.forEach(([name, expr]) => {
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
- type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope }
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
- // both sources normalize to [at, item] pairs: the index for an array, the
1490
- // property key for a plain object (insertion order). Object entries are
1491
- // read off the store proxy, so each value is tracked under its own key -
1492
- // adds, deletes and changes all wake this effect
1493
- const pairs: [any, any][] = Array.isArray(list)
1494
- ? list.map((item, index): [any, any] => [index, item])
1495
- : isPlainObject(list) ? Object.entries(list) : []
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 = pairs.map(([at, item], index): EachEntry => {
1510
- const itemScope = Object.create(scope)
1511
- defineScopeVar(itemScope, itemName, item)
1512
- if (atName) defineScopeVar(itemScope, atName, at)
1513
- defineScopeVar(itemScope, "$index", index)
1514
- const key = keyExpr !== undefined ? evalExpr(keyExpr, itemScope) : at
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
- if (readsPosition && existing.scope.$index !== index) moved.push(existing)
1524
- defineScopeVar(existing.scope, "$index", index)
1525
- if (atName) defineScopeVar(existing.scope, atName, at)
1526
- return existing
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
- return { key, item, scope: itemScope, fx: itemFx, range }
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 => entry.fx.dispose())
1548
- removeRuns(contiguousRuns(entries, entry => dead.has(entry)))
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
- nextEntries.forEach(entry => {
1553
- if (prevNode.nextSibling !== entry.range.first) moveRangeAfter(entry.range, prevNode)
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
- if (node.includes("{{")) fx.effect(() => { textNode.textContent = interpolate(node, scope) })
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