jq79 0.5.14 → 0.6.1

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.
@@ -4,10 +4,14 @@ type ListenerOptions = {
4
4
  immediate?: boolean;
5
5
  };
6
6
  type Unsubscribe = () => void;
7
+ export type EffectOptions = {
8
+ deep?: boolean;
9
+ alsoWakenBy?: Record<string, any>[];
10
+ };
7
11
  export type ReactiveDeepData<T> = T & {
8
12
  $on: (dotKey: string, listener: ChangeListener, options?: ListenerOptions) => Unsubscribe;
9
13
  $onAny: (listener: AnyChangeListener, options?: ListenerOptions) => Unsubscribe;
10
- $effect: (run: () => void, alsoWakenBy?: Record<string, any>[]) => Unsubscribe;
14
+ $effect: (run: () => void, options?: EffectOptions) => Unsubscribe;
11
15
  $dispose: () => void;
12
16
  };
13
17
  export declare const $toRaw: <T>(value: T) => T;
@@ -20,5 +24,5 @@ export type EffectScope = {
20
24
  refresh: () => void;
21
25
  dispose: () => void;
22
26
  };
23
- export declare const createEffectScope: (scope: Record<string, any>) => EffectScope;
27
+ export declare const createEffectScope: (scope: Record<string, any>, deep?: boolean) => EffectScope;
24
28
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.5.14",
3
+ "version": "0.6.1",
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",
package/src/jq79.ts CHANGED
@@ -373,6 +373,43 @@ const removeRange = ({ first, last }: NodeRange) => {
373
373
  }
374
374
  }
375
375
 
376
+ // removes several ranges that sit next to each other, in one DOM call each run
377
+ // rather than one per node. A list dropping all its rows hands them over as a
378
+ // single span: unlinking 10,000 rows one at a time is 40% of that operation,
379
+ // profiled - see TODOS/2026-08-23.batch-range-removal.md. Runs are built by the
380
+ // caller, which is the only place that knows what else is going
381
+ const removeRuns = (runs: NodeRange[][]) => {
382
+ runs.forEach(run => {
383
+ if (run.length === 1) return removeRange(run[0])
384
+ const parent = run[0].first.parentNode
385
+ if (!parent) return
386
+ // both ends sit between nodes, so nothing is partially selected and whole
387
+ // nodes are what gets unlinked
388
+ const range = document.createRange()
389
+ range.setStartBefore(run[0].first)
390
+ range.setEndAfter(run[run.length - 1].last)
391
+ range.deleteContents()
392
+ })
393
+ }
394
+
395
+ // groups the entries `isDead` selects into runs of DOM neighbours, walking
396
+ // `ordered` (which is in DOM order). Adjacency is confirmed rather than assumed:
397
+ // a gap - an entry removed earlier in the same pass - starts a new run, so a
398
+ // 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
402
+ ordered.forEach(entry => {
403
+ if (!isDead(entry)) {
404
+ run = null
405
+ return
406
+ }
407
+ if (run && run[run.length - 1].last.nextSibling === entry.range.first) run.push(entry.range)
408
+ else runs.push((run = [entry.range]))
409
+ })
410
+ return runs
411
+ }
412
+
376
413
  // moves [first..last] inclusive so the range starts right after `prev`
377
414
  const moveRangeAfter = ({ first, last }: NodeRange, prev: Node) => {
378
415
  const ref = prev.nextSibling
@@ -388,9 +425,69 @@ const moveRangeAfter = ({ first, last }: NodeRange, prev: Node) => {
388
425
  // case-insensitive with dashes stripped (<nested-component> works too). Only
389
426
  // PascalCase scope keys participate, so ordinary variables named like real
390
427
  // elements (title, code, ...) never hijack them
428
+ const scanComponentKey = (scope: Record<string, any>, tag: string): string | null => {
429
+ const normalized = tag.replace(/-/g, "").toLowerCase()
430
+ for (let obj: any = scope; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
431
+ for (const key of Object.keys(obj)) {
432
+ if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
433
+ }
434
+ }
435
+ return null
436
+ }
437
+
438
+ // The scan above is run for every element rendered, and it walks the whole
439
+ // scope chain calling Object.keys at each level - which profiling puts at 13.7%
440
+ // of the create path, four times the next attributable frame, almost all of it
441
+ // answering "no" for tags like <td>. Within one render pass the answer can't
442
+ // change: it is a *key*, not the value behind it, so every row of a :each
443
+ // resolves a tag identically, and a store write mid-pass restarts the pass
444
+ // rather than continuing it (the reentrancy guard in reactive.ts).
445
+ //
446
+ // So it is memoized for exactly one pass and no longer. It has to be no longer:
447
+ // a template renders before its setup script settles, so `const Row = await
448
+ // $import(...)` arrives as a new store key *after* elements are on the page -
449
+ // and a cached "no component called Row" that outlived the pass would never be
450
+ // revisited. See TODOS/2026-08-23.component-key-scan.md
451
+ // The memo answers for one *base* scope - the one the pass was opened with -
452
+ // and nothing below it. A lookup walks from wherever it starts up to that base,
453
+ // checking own keys as it goes (an :each item scope has two or three, a :with
454
+ // a handful), and only then consults the memo. So a name introduced under the
455
+ // base still shadows correctly, and a scope that never reaches the base at all
456
+ // - a nested component renders against its own store - has simply been fully
457
+ // scanned by the time the walk ends, which is the answer anyway
458
+ let tagMemo: Map<string, string | null> | null = null
459
+ let memoBase: object | null = null
460
+
461
+ // opened and closed by hand rather than by a wrapper taking a callback: a
462
+ // component that renders itself through :each stacks one renderEach per level,
463
+ // and a callback would add a frame to each of them. The cyclic-component test
464
+ // cuts off at 200 levels, and on a CI runner that extra frame per level was the
465
+ // difference between cutting off and a RangeError - the same reason $effect
466
+ // keeps its own shape (see reactive.ts)
467
+ type RenderPass = { memo: Map<string, string | null> | null; base: object | null }
468
+
469
+ const openRenderPass = (base: Record<string, any>): RenderPass => {
470
+ const outer: RenderPass = { memo: tagMemo, base: memoBase }
471
+ tagMemo = new Map()
472
+ memoBase = base
473
+ return outer
474
+ }
475
+
476
+ const closeRenderPass = (outer: RenderPass) => {
477
+ tagMemo = outer.memo
478
+ memoBase = outer.base
479
+ }
480
+
391
481
  const findComponentKey = (scope: Record<string, any>, tag: string): string | null => {
482
+ if (!tagMemo) return scanComponentKey(scope, tag)
392
483
  const normalized = tag.replace(/-/g, "").toLowerCase()
393
484
  for (let obj: any = scope; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
485
+ if (obj === memoBase) {
486
+ if (tagMemo.has(tag)) return tagMemo.get(tag)!
487
+ const key = scanComponentKey(obj, tag)
488
+ tagMemo.set(tag, key)
489
+ return key
490
+ }
394
491
  for (const key of Object.keys(obj)) {
395
492
  if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
396
493
  }
@@ -902,7 +999,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
902
999
  }
903
1000
  endAnchor.parentNode!.insertBefore(holder, endAnchor)
904
1001
 
905
- const syncFx = createEffectScope(scope)
1002
+ // deep: a prop sync forwards whatever the expression evaluates to, whole,
1003
+ // into the child's store - it reads `user`, never `user.name`, so it can't
1004
+ // track what it passes on. A parent's deep mutation reaches the child
1005
+ // through this effect or not at all (see $effect's `deep`)
1006
+ const syncFx = createEffectScope(scope, true)
906
1007
  // without a spread the prop set is fixed and known: one effect per prop, so
907
1008
  // a change to one prop re-syncs only that prop. A spread's key set is
908
1009
  // dynamic and its precedence is positional, so it can't be resolved a key at
@@ -1217,9 +1318,9 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1217
1318
  // for them. They render (bindings and all) and go there - appended as
1218
1319
  // childNodes they would be in the DOM but in no document fragment, seen by
1219
1320
  // nothing and rendered by nobody
1220
- el.content.appendChild(renderNodes(node.children, scope, fx, shadow))
1321
+ renderNodes(node.children, scope, fx, shadow, el.content)
1221
1322
  } else {
1222
- el.appendChild(renderNodes(node.children, scope, fx, shadow))
1323
+ renderNodes(node.children, scope, fx, shadow, el)
1223
1324
  }
1224
1325
 
1225
1326
  // :value / :checked / :selected write the DOM *property*, not the
@@ -1288,12 +1389,59 @@ const renderConditional = (branches: ConditionalBranch[], scope: Record<string,
1288
1389
  // reactive proxy's `set` trap: it would wrap `value` as if it were a genuine
1289
1390
  // store mutation and fire a bogus notify() under a name (e.g. "item") shared
1290
1391
  // by every unrelated item in every :each on the page. defineProperty always
1291
- // writes to `scope` itself, never delegating, so this can't happen
1392
+ // writes to `scope` itself, never delegating, so this can't happen.
1393
+ //
1394
+ // Only while the key is *missing*, though: once this has run, the property is
1395
+ // own and writable, and a plain assignment finds it there and writes it in
1396
+ // place. That is what renderEach's reuse paths do, and why they may - a
1397
+ // defineProperty costs several times an assignment, and a list that never
1398
+ // reorders would otherwise pay one per row per pass to rewrite the value it
1399
+ // already held
1292
1400
  const defineScopeVar = (scope: Record<string, any>, key: string, value: any) => {
1293
1401
  Object.defineProperty(scope, key, { value, writable: true, enumerable: true, configurable: true })
1294
1402
  }
1295
1403
 
1296
- type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope }
1404
+ // `pos` is where the entry sat in the previous pass, refreshed as the buckets
1405
+ // are built - the positioning pass needs the old order to work out which rows
1406
+ // are already where they belong (see longestIncreasingRun)
1407
+ type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope; pos: number }
1408
+
1409
+ // the indices of one longest strictly increasing subsequence of `positions`,
1410
+ // as a flag per index. Fed the old position of every entry in the new order
1411
+ // (-1 for one rendered this pass), it names the rows that are ALREADY in the
1412
+ // right order relative to each other: move everything else and the pass issues
1413
+ // the fewest insertions it can. The walk this serves used to demand only that
1414
+ // each entry follow the one before it, which is minimal for an append and
1415
+ // quadratic for a reorder - one row out of place cascaded into a move for
1416
+ // every row after it, so swapping rows 1 and 998 of a 1,000-row table issued
1417
+ // 997 insertBefore calls where two would do.
1418
+ // Patience sorting, O(n log n): `tails[l]` is the index of the smallest value
1419
+ // that can end an increasing run of length l+1, and `before` remembers what
1420
+ // each index was appended to, which is what makes the run reconstructible
1421
+ const longestIncreasingRun = (positions: number[]): Uint8Array => {
1422
+ const inRun = new Uint8Array(positions.length)
1423
+ const tails: number[] = []
1424
+ const before = new Int32Array(positions.length).fill(-1)
1425
+
1426
+ for (let index = 0; index < positions.length; index++) {
1427
+ const position = positions[index]
1428
+ if (position < 0) continue // rendered this pass: it has no old position to be in order with
1429
+ let low = 0
1430
+ let high = tails.length
1431
+ while (low < high) {
1432
+ const mid = (low + high) >> 1
1433
+ if (positions[tails[mid]] < position) low = mid + 1
1434
+ else high = mid
1435
+ }
1436
+ if (low > 0) before[index] = tails[low - 1]
1437
+ tails[low] = index
1438
+ }
1439
+
1440
+ for (let index = tails.length ? tails[tails.length - 1] : -1; index !== -1; index = before[index]) {
1441
+ inRun[index] = 1
1442
+ }
1443
+ return inRun
1444
+ }
1297
1445
 
1298
1446
  // what :each iterates besides arrays: dictionaries, as their entries. Class
1299
1447
  // instances, Maps and the rest stay out - the store doesn't wrap them
@@ -1314,15 +1462,76 @@ const isPlainObject = (value: any): value is Record<string, any> => {
1314
1462
  // property key, which is already the stable identity. Each item gets its own
1315
1463
  // scope via Object.create(scope), so the bindings and `$index` shadow
1316
1464
  // same-named outer names without copying the parent scope's keys
1465
+ // does anything in this subtree name one of `names` as an identifier? Attribute
1466
+ // values and text alike, since either can hold an expression - a prop handing a
1467
+ // position to a nested component (`<Row :n="$index">`) is an attribute on a node
1468
+ // inside the item, which is why the walk has to cover children's attrs too.
1469
+ //
1470
+ // Over-approximating is the safe direction and the intended one: a name that
1471
+ // appears in a string literal costs a refresh that wasn't needed, which is
1472
+ // exactly what happens today for every list. Missing one would leave a binding
1473
+ // stale, and the walk cannot - a template expression is source text
1474
+ const mentionsAny = (node: TemplateNode | string, names: string[]): boolean => {
1475
+ if (typeof node === "string") return names.some(name => identifierIn(node, name))
1476
+ return Object.values(node.attrs).some(value => names.some(name => identifierIn(value, name))) ||
1477
+ node.children.some(child => mentionsAny(child, names))
1478
+ }
1479
+
1480
+ // `name` as a whole word: `$index` must not match inside `$indexes`, and `i`
1481
+ // must not match inside `items`. `$` counts as a word character here, which is
1482
+ // why the boundaries are checked by hand rather than with \b - \b treats `$`
1483
+ // as a boundary and would find the `i` of `$index` when looking for `i`
1484
+ const IDENTIFIER_CHAR = /[A-Za-z0-9_$]/
1485
+
1486
+ // `a.b` and nothing else: two plain identifiers, one dot. A deeper path could
1487
+ // walk a null halfway down, which is a diagnostic evalExpr owns
1488
+ const KEY_MEMBER = /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/
1489
+
1490
+ const identifierIn = (text: string, name: string): boolean => {
1491
+ for (let at = text.indexOf(name); at !== -1; at = text.indexOf(name, at + 1)) {
1492
+ const before = at === 0 ? "" : text[at - 1]
1493
+ const after = text[at + name.length] ?? ""
1494
+ if (!IDENTIFIER_CHAR.test(before) && !IDENTIFIER_CHAR.test(after)) return true
1495
+ }
1496
+ return false
1497
+ }
1498
+
1317
1499
  const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1318
1500
  const match = node.attrs[":each"].match(EACH_PATTERN)
1319
1501
  if (!match) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
1320
1502
 
1321
1503
  const [, itemName, atName, listExpr] = match
1322
1504
  const keyExpr = node.attrs[":key"]
1505
+
1506
+ // `:key="row.id"`, or the loop variable itself, is what a key almost always
1507
+ // is - and reading one needs neither a scope to resolve names against nor a
1508
+ // compiled expression, because the item is already in hand. A pass evaluates
1509
+ // one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
1510
+ // the store proxy to discover that nothing had changed: most of the 37% of a
1511
+ // pass that goes on evaluating expressions
1512
+ // (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
1513
+ // an index, a deeper path, a name from the outer scope - still goes through
1514
+ // evalExpr, and so does a non-object item, which keeps every diagnostic a
1515
+ // property read of a null row would have raised
1516
+ const keyIsItem = keyExpr === itemName
1517
+ const keyProp = keyExpr !== undefined && !keyIsItem && KEY_MEMBER.test(keyExpr) && keyExpr.startsWith(`${itemName}.`)
1518
+ ? keyExpr.slice(itemName.length + 1)
1519
+ : undefined
1323
1520
  const { [":each"]: _each, [":key"]: _key, ...itemAttrs } = node.attrs
1324
1521
  const itemNode: TemplateNode = { ...node, attrs: itemAttrs }
1325
1522
 
1523
+ // An entry that changed position needs its position-only bindings re-run -
1524
+ // `$index` and the `, at` name are plain scope vars, untracked by design, so
1525
+ // nothing can wake them (see EffectScope.refresh). But refresh re-runs *every*
1526
+ // effect on the entry, and in a list whose template names no position at all
1527
+ // - the common one - all of that recomputes strings that cannot have changed:
1528
+ // it was 9ms of removeRow's 25ms. Decided once, from the template, rather
1529
+ // than per row per render. The item name is deliberately not in this list:
1530
+ // nearly every binding reads it, and it is not what goes stale.
1531
+ // See TODOS/2026-08-23.positional-refresh.md
1532
+ const positionalNames = ["$index", ...(atName ? [atName] : [])]
1533
+ const readsPosition = mentionsAny(itemNode, positionalNames)
1534
+
1326
1535
  const anchor = document.createComment("each")
1327
1536
  const wrapper = document.createDocumentFragment()
1328
1537
  wrapper.appendChild(anchor)
@@ -1336,79 +1545,146 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1336
1545
  let entries: EachEntry[] = []
1337
1546
  let warnedDuplicates = false
1338
1547
 
1548
+ // one memo for the whole diff: every row resolves its tags to the same scope
1549
+ // keys, so the scan that used to run per element per row now runs once per
1550
+ // distinct tag (see findComponentKey)
1339
1551
  fx.effect(() => {
1340
- const list = evalExpr(listExpr, scope)
1341
- // both sources normalize to [at, item] pairs: the index for an array, the
1342
- // property key for a plain object (insertion order). Object entries are
1343
- // read off the store proxy, so each value is tracked under its own key -
1344
- // adds, deletes and changes all wake this effect
1345
- const pairs: [any, any][] = Array.isArray(list)
1346
- ? list.map((item, index): [any, any] => [index, item])
1347
- : isPlainObject(list) ? Object.entries(list) : []
1348
- // buckets rather than a key->entry map: duplicate keys (a user error, but
1349
- // one that must degrade instead of corrupt) consume entries in order of
1350
- // appearance, so no entry is ever matched twice - matching one twice is
1351
- // how a reused row got disposed and a removed one resurrected
1352
- const previous = new Map<any, EachEntry[]>()
1353
- entries.forEach(entry => {
1354
- const bucket = previous.get(entry.key)
1355
- if (bucket) bucket.push(entry)
1356
- else previous.set(entry.key, [entry])
1357
- })
1552
+ const pass = openRenderPass(scope)
1553
+ try {
1554
+ const list = evalExpr(listExpr, scope)
1555
+ // read the source in place rather than normalizing it to [at, item] pairs
1556
+ // first: a list of 1,000 rows built 1,000 two-element arrays per pass,
1557
+ // every one of them garbage by the end of it. `keys` carries a plain
1558
+ // object's property list (insertion order, which is also its identity)
1559
+ // and is null for an array, where the index is the key. Either way the
1560
+ // items are read off the store proxy one at a time, so each stays tracked
1561
+ // under its own key - adds, deletes and changes all wake this effect
1562
+ const array = Array.isArray(list)
1563
+ const keys: string[] | null = array ? null : isPlainObject(list) ? Object.keys(list) : []
1564
+ const length = array ? list.length : keys!.length
1565
+ // buckets rather than a key->entry map: duplicate keys (a user error, but
1566
+ // one that must degrade instead of corrupt) consume entries in order of
1567
+ // appearance, so no entry is ever matched twice - matching one twice is
1568
+ // how a reused row got disposed and a removed one resurrected
1569
+ const previous = new Map<any, EachEntry[]>()
1570
+ entries.forEach((entry, index) => {
1571
+ entry.pos = index
1572
+ const bucket = previous.get(entry.key)
1573
+ if (bucket) bucket.push(entry)
1574
+ else previous.set(entry.key, [entry])
1575
+ })
1358
1576
 
1359
- const seen = new Set<any>()
1360
- const moved: EachEntry[] = []
1361
- const nextEntries = pairs.map(([at, item], index): EachEntry => {
1362
- const itemScope = Object.create(scope)
1363
- defineScopeVar(itemScope, itemName, item)
1364
- if (atName) defineScopeVar(itemScope, atName, at)
1365
- defineScopeVar(itemScope, "$index", index)
1366
- const key = keyExpr !== undefined ? evalExpr(keyExpr, itemScope) : at
1367
- if (seen.has(key) && !warnedDuplicates) {
1368
- warnedDuplicates = true
1369
- console.warn(`jq79: duplicate :key in :each "${node.attrs[":each"]}"; duplicates pair up by position`)
1370
- }
1371
- seen.add(key)
1372
- const existing = previous.get(key)?.shift()
1373
-
1374
- if (existing && Object.is(existing.item, item)) {
1375
- if (existing.scope.$index !== index) moved.push(existing)
1376
- defineScopeVar(existing.scope, "$index", index)
1377
- if (atName) defineScopeVar(existing.scope, atName, at)
1378
- return existing
1379
- }
1577
+ const seen = new Set<any>()
1578
+ const moved: EachEntry[] = []
1579
+ const nextEntries: EachEntry[] = []
1580
+ // each entry's position in the previous pass, in the new order, for the
1581
+ // positioning walk below - -1 for one rendered here, which has none
1582
+ const positions: number[] = []
1583
+ // one scratch scope for the whole pass, not one per item. A :key
1584
+ // expression reads the item by name (`row.id`), so it needs a scope to
1585
+ // read it from - but which entry that key names, and so whether anything
1586
+ // has to be rendered at all, is only known once it has been evaluated.
1587
+ // Building the entry's real scope up front meant every reused row - the
1588
+ // common case, and nearly all of removeRow, selectRow and swapRows - paid
1589
+ // for an object and three defineProperty calls that were then dropped on
1590
+ // the floor. Nothing outlives the evaluation, which is synchronous, so one
1591
+ // scratch serves the whole list; a row that really is rendered gets a
1592
+ // scope of its own, below
1593
+ let scratch: Record<string, any> | undefined
1594
+
1595
+ for (let index = 0; index < length; index++) {
1596
+ const at = array ? index : keys![index]
1597
+ const item = array ? list[index] : list[at]
1598
+
1599
+ let key: any = at
1600
+ if (keyIsItem) key = item
1601
+ else if (keyProp !== undefined && item !== null && typeof item === "object") key = item[keyProp]
1602
+ else if (keyExpr !== undefined) {
1603
+ if (scratch === undefined) {
1604
+ scratch = Object.create(scope) as Record<string, any>
1605
+ defineScopeVar(scratch, itemName, item)
1606
+ if (atName) defineScopeVar(scratch, atName, at)
1607
+ defineScopeVar(scratch, "$index", index)
1608
+ } else {
1609
+ // own and writable already, so a plain assignment writes to
1610
+ // `scratch` itself - the delegation defineScopeVar exists to
1611
+ // prevent can only happen while the key is missing from it
1612
+ scratch[itemName] = item
1613
+ if (atName) scratch[atName] = at
1614
+ scratch.$index = index
1615
+ }
1616
+ key = evalExpr(keyExpr, scratch)
1617
+ }
1380
1618
 
1381
- if (existing) {
1382
- existing.fx.dispose()
1383
- removeRange(existing.range)
1384
- }
1619
+ if (seen.has(key) && !warnedDuplicates) {
1620
+ warnedDuplicates = true
1621
+ console.warn(`jq79: duplicate :key in :each "${node.attrs[":each"]}"; duplicates pair up by position`)
1622
+ }
1623
+ seen.add(key)
1624
+ const existing = previous.get(key)?.shift()
1625
+
1626
+ if (existing && Object.is(existing.item, item)) {
1627
+ // same reason as `scratch`: these are own writable properties of the
1628
+ // entry's scope from the moment it was rendered, and writing only
1629
+ // what moved keeps a list that never reorders from paying anything
1630
+ const entryScope = existing.scope
1631
+ if (entryScope.$index !== index) {
1632
+ if (readsPosition) moved.push(existing)
1633
+ entryScope.$index = index
1634
+ }
1635
+ if (atName && entryScope[atName] !== at) entryScope[atName] = at
1636
+ nextEntries.push(existing)
1637
+ positions.push(existing.pos)
1638
+ continue
1639
+ }
1385
1640
 
1386
- const itemFx = createEffectScope(scope)
1387
- // bounds captured before the positioning pass inserts the entry: a
1388
- // component entry is a fragment, which empties on insertion (see boundsOf)
1389
- const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
1390
- return { key, item, scope: itemScope, fx: itemFx, range }
1391
- })
1641
+ if (existing) {
1642
+ existing.fx.dispose()
1643
+ removeRange(existing.range)
1644
+ }
1392
1645
 
1393
- // whatever no new item consumed is gone
1394
- previous.forEach(bucket => bucket.forEach(entry => {
1395
- entry.fx.dispose()
1396
- removeRange(entry.range)
1397
- }))
1646
+ const itemScope = Object.create(scope)
1647
+ defineScopeVar(itemScope, itemName, item)
1648
+ if (atName) defineScopeVar(itemScope, atName, at)
1649
+ defineScopeVar(itemScope, "$index", index)
1650
+ const itemFx = createEffectScope(scope)
1651
+ // bounds captured before the positioning pass inserts the entry: a
1652
+ // component entry is a fragment, which empties on insertion (see boundsOf)
1653
+ const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
1654
+ nextEntries.push({ key, item, scope: itemScope, fx: itemFx, range, pos: index })
1655
+ positions.push(-1)
1656
+ }
1398
1657
 
1399
- let prevNode: Node = anchor
1400
- nextEntries.forEach(entry => {
1401
- if (prevNode.nextSibling !== entry.range.first) moveRangeAfter(entry.range, prevNode)
1402
- prevNode = entry.range.last
1403
- })
1658
+ // whatever no new item consumed is gone. Effects are torn down one by one
1659
+ // as always; the DOM goes in runs of neighbours, which is what makes
1660
+ // clearing a long list one mutation instead of one per row
1661
+ const dead = new Set<EachEntry>()
1662
+ previous.forEach(bucket => bucket.forEach(entry => dead.add(entry)))
1663
+ if (dead.size) {
1664
+ dead.forEach(entry => entry.fx.dispose())
1665
+ removeRuns(contiguousRuns(entries, entry => dead.has(entry)))
1666
+ }
1667
+
1668
+ let prevNode: Node = anchor
1669
+ // rows already in order relative to each other stay where they are; the
1670
+ // rest are placed after the row that precedes them in the new order,
1671
+ // which is where the pass has just left `prevNode`
1672
+ const inPlace = longestIncreasingRun(positions)
1673
+ nextEntries.forEach((entry, index) => {
1674
+ if (!inPlace[index] && prevNode.nextSibling !== entry.range.first) moveRangeAfter(entry.range, prevNode)
1675
+ prevNode = entry.range.last
1676
+ })
1404
1677
 
1405
- // reused entries that changed position: their tracked bindings re-run off
1406
- // the list notification anyway, but a binding that reads only `$index` or
1407
- // the named key tracked nothing - refresh them so the move reaches those
1408
- // too. Untracked, so these runs don't feed this list effect's own deps
1409
- moved.forEach(entry => untracked(() => entry.fx.refresh()))
1678
+ // reused entries that changed position: their tracked bindings re-run off
1679
+ // the list notification anyway, but a binding that reads only `$index` or
1680
+ // the named key tracked nothing - refresh them so the move reaches those
1681
+ // too. Untracked, so these runs don't feed this list effect's own deps
1682
+ moved.forEach(entry => untracked(() => entry.fx.refresh()))
1410
1683
 
1411
- entries = nextEntries
1684
+ entries = nextEntries
1685
+ } finally {
1686
+ closeRenderPass(pass)
1687
+ }
1412
1688
  })
1413
1689
 
1414
1690
  return wrapper
@@ -1416,8 +1692,21 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1416
1692
 
1417
1693
  // renders a list of sibling template nodes (text + elements), grouping
1418
1694
  // consecutive :if/:elseif/:else nodes into a single conditional block
1419
- const renderNodes = (nodes: (TemplateNode | string)[], scope: Record<string, any>, fx: EffectScope, shadow = false): DocumentFragment => {
1420
- const fragment = document.createDocumentFragment()
1695
+ // `into` renders straight into an element that is not in the document yet -
1696
+ // what renderElement does for an element's own children. Every element used to
1697
+ // get a DocumentFragment of its own, filled and then emptied into it: for a
1698
+ // 10,000-row table that is 70,000 fragments and a second pass over every node,
1699
+ // and the intermediate is invisible either way because the element is still
1700
+ // detached. Callers that need a standalone chunk (a component's content, an
1701
+ // :if branch) omit it and get the fragment
1702
+ const renderNodes = <T extends ParentNode>(
1703
+ nodes: (TemplateNode | string)[],
1704
+ scope: Record<string, any>,
1705
+ fx: EffectScope,
1706
+ shadow = false,
1707
+ into?: T
1708
+ ): T | DocumentFragment => {
1709
+ const fragment = into ?? document.createDocumentFragment()
1421
1710
  let i = 0
1422
1711
 
1423
1712
  while (i < nodes.length) {