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/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 +11 -11
- package/dist/jq79.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +145 -24
- package/src/reactive.ts +1 -1
package/package.json
CHANGED
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
|
-
|
|
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
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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 =
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1553
|
-
|
|
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
|
|
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
|