jq79 0.6.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.6.0",
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
@@ -1389,12 +1389,59 @@ const renderConditional = (branches: ConditionalBranch[], scope: Record<string,
1389
1389
  // reactive proxy's `set` trap: it would wrap `value` as if it were a genuine
1390
1390
  // store mutation and fire a bogus notify() under a name (e.g. "item") shared
1391
1391
  // by every unrelated item in every :each on the page. defineProperty always
1392
- // 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
1393
1400
  const defineScopeVar = (scope: Record<string, any>, key: string, value: any) => {
1394
1401
  Object.defineProperty(scope, key, { value, writable: true, enumerable: true, configurable: true })
1395
1402
  }
1396
1403
 
1397
- 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
+ }
1398
1445
 
1399
1446
  // what :each iterates besides arrays: dictionaries, as their entries. Class
1400
1447
  // instances, Maps and the rest stay out - the store doesn't wrap them
@@ -1436,6 +1483,10 @@ const mentionsAny = (node: TemplateNode | string, names: string[]): boolean => {
1436
1483
  // as a boundary and would find the `i` of `$index` when looking for `i`
1437
1484
  const IDENTIFIER_CHAR = /[A-Za-z0-9_$]/
1438
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
+
1439
1490
  const identifierIn = (text: string, name: string): boolean => {
1440
1491
  for (let at = text.indexOf(name); at !== -1; at = text.indexOf(name, at + 1)) {
1441
1492
  const before = at === 0 ? "" : text[at - 1]
@@ -1451,6 +1502,21 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1451
1502
 
1452
1503
  const [, itemName, atName, listExpr] = match
1453
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
1454
1520
  const { [":each"]: _each, [":key"]: _key, ...itemAttrs } = node.attrs
1455
1521
  const itemNode: TemplateNode = { ...node, attrs: itemAttrs }
1456
1522
 
@@ -1486,19 +1552,23 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1486
1552
  const pass = openRenderPass(scope)
1487
1553
  try {
1488
1554
  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) : []
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
1496
1565
  // buckets rather than a key->entry map: duplicate keys (a user error, but
1497
1566
  // one that must degrade instead of corrupt) consume entries in order of
1498
1567
  // appearance, so no entry is ever matched twice - matching one twice is
1499
1568
  // how a reused row got disposed and a removed one resurrected
1500
1569
  const previous = new Map<any, EachEntry[]>()
1501
- entries.forEach(entry => {
1570
+ entries.forEach((entry, index) => {
1571
+ entry.pos = index
1502
1572
  const bucket = previous.get(entry.key)
1503
1573
  if (bucket) bucket.push(entry)
1504
1574
  else previous.set(entry.key, [entry])
@@ -1506,12 +1576,46 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1506
1576
 
1507
1577
  const seen = new Set<any>()
1508
1578
  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
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
+ }
1618
+
1515
1619
  if (seen.has(key) && !warnedDuplicates) {
1516
1620
  warnedDuplicates = true
1517
1621
  console.warn(`jq79: duplicate :key in :each "${node.attrs[":each"]}"; duplicates pair up by position`)
@@ -1520,10 +1624,18 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1520
1624
  const existing = previous.get(key)?.shift()
1521
1625
 
1522
1626
  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
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
1527
1639
  }
1528
1640
 
1529
1641
  if (existing) {
@@ -1531,12 +1643,17 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1531
1643
  removeRange(existing.range)
1532
1644
  }
1533
1645
 
1646
+ const itemScope = Object.create(scope)
1647
+ defineScopeVar(itemScope, itemName, item)
1648
+ if (atName) defineScopeVar(itemScope, atName, at)
1649
+ defineScopeVar(itemScope, "$index", index)
1534
1650
  const itemFx = createEffectScope(scope)
1535
1651
  // bounds captured before the positioning pass inserts the entry: a
1536
1652
  // component entry is a fragment, which empties on insertion (see boundsOf)
1537
1653
  const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
1538
- return { key, item, scope: itemScope, fx: itemFx, range }
1539
- })
1654
+ nextEntries.push({ key, item, scope: itemScope, fx: itemFx, range, pos: index })
1655
+ positions.push(-1)
1656
+ }
1540
1657
 
1541
1658
  // whatever no new item consumed is gone. Effects are torn down one by one
1542
1659
  // as always; the DOM goes in runs of neighbours, which is what makes
@@ -1549,8 +1666,12 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1549
1666
  }
1550
1667
 
1551
1668
  let prevNode: Node = anchor
1552
- nextEntries.forEach(entry => {
1553
- if (prevNode.nextSibling !== entry.range.first) moveRangeAfter(entry.range, prevNode)
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)
1554
1675
  prevNode = entry.range.last
1555
1676
  })
1556
1677
 
package/src/reactive.ts CHANGED
@@ -595,7 +595,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
595
595
  return Reflect.has(target, key) || (typeof key === "string" && tombstones?.has(key) === true)
596
596
  },
597
597
  // reading the key set is a dependency of its own: `Object.keys(props)`,
598
- // `{...props}`, `for...in` and renderEach's `Object.entries` all care
598
+ // `{...props}`, `for...in` and renderEach's walk of a list's keys all care
599
599
  // about which keys exist, not about what any one of them holds. It used
600
600
  // to be caught only by the coarse ancestor rule, which is now gone -
601
601
  // this is the same job Svelte gives a per-object `version` signal, held