jq79 0.5.13 → 0.6.0

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.13",
3
+ "version": "0.6.0",
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:comparison": "node scripts/run-comparison.mjs",
68
69
  "prepublishOnly": "npm test && npm run build"
69
70
  },
70
71
  "peerDependencies": {
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
@@ -1314,6 +1415,36 @@ const isPlainObject = (value: any): value is Record<string, any> => {
1314
1415
  // property key, which is already the stable identity. Each item gets its own
1315
1416
  // scope via Object.create(scope), so the bindings and `$index` shadow
1316
1417
  // same-named outer names without copying the parent scope's keys
1418
+ // does anything in this subtree name one of `names` as an identifier? Attribute
1419
+ // values and text alike, since either can hold an expression - a prop handing a
1420
+ // position to a nested component (`<Row :n="$index">`) is an attribute on a node
1421
+ // inside the item, which is why the walk has to cover children's attrs too.
1422
+ //
1423
+ // Over-approximating is the safe direction and the intended one: a name that
1424
+ // appears in a string literal costs a refresh that wasn't needed, which is
1425
+ // exactly what happens today for every list. Missing one would leave a binding
1426
+ // stale, and the walk cannot - a template expression is source text
1427
+ const mentionsAny = (node: TemplateNode | string, names: string[]): boolean => {
1428
+ if (typeof node === "string") return names.some(name => identifierIn(node, name))
1429
+ return Object.values(node.attrs).some(value => names.some(name => identifierIn(value, name))) ||
1430
+ node.children.some(child => mentionsAny(child, names))
1431
+ }
1432
+
1433
+ // `name` as a whole word: `$index` must not match inside `$indexes`, and `i`
1434
+ // must not match inside `items`. `$` counts as a word character here, which is
1435
+ // why the boundaries are checked by hand rather than with \b - \b treats `$`
1436
+ // as a boundary and would find the `i` of `$index` when looking for `i`
1437
+ const IDENTIFIER_CHAR = /[A-Za-z0-9_$]/
1438
+
1439
+ const identifierIn = (text: string, name: string): boolean => {
1440
+ for (let at = text.indexOf(name); at !== -1; at = text.indexOf(name, at + 1)) {
1441
+ const before = at === 0 ? "" : text[at - 1]
1442
+ const after = text[at + name.length] ?? ""
1443
+ if (!IDENTIFIER_CHAR.test(before) && !IDENTIFIER_CHAR.test(after)) return true
1444
+ }
1445
+ return false
1446
+ }
1447
+
1317
1448
  const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1318
1449
  const match = node.attrs[":each"].match(EACH_PATTERN)
1319
1450
  if (!match) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
@@ -1323,6 +1454,18 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1323
1454
  const { [":each"]: _each, [":key"]: _key, ...itemAttrs } = node.attrs
1324
1455
  const itemNode: TemplateNode = { ...node, attrs: itemAttrs }
1325
1456
 
1457
+ // An entry that changed position needs its position-only bindings re-run -
1458
+ // `$index` and the `, at` name are plain scope vars, untracked by design, so
1459
+ // nothing can wake them (see EffectScope.refresh). But refresh re-runs *every*
1460
+ // effect on the entry, and in a list whose template names no position at all
1461
+ // - the common one - all of that recomputes strings that cannot have changed:
1462
+ // it was 9ms of removeRow's 25ms. Decided once, from the template, rather
1463
+ // than per row per render. The item name is deliberately not in this list:
1464
+ // nearly every binding reads it, and it is not what goes stale.
1465
+ // See TODOS/2026-08-23.positional-refresh.md
1466
+ const positionalNames = ["$index", ...(atName ? [atName] : [])]
1467
+ const readsPosition = mentionsAny(itemNode, positionalNames)
1468
+
1326
1469
  const anchor = document.createComment("each")
1327
1470
  const wrapper = document.createDocumentFragment()
1328
1471
  wrapper.appendChild(anchor)
@@ -1336,79 +1479,91 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1336
1479
  let entries: EachEntry[] = []
1337
1480
  let warnedDuplicates = false
1338
1481
 
1482
+ // one memo for the whole diff: every row resolves its tags to the same scope
1483
+ // keys, so the scan that used to run per element per row now runs once per
1484
+ // distinct tag (see findComponentKey)
1339
1485
  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
- })
1486
+ const pass = openRenderPass(scope)
1487
+ try {
1488
+ 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) : []
1496
+ // buckets rather than a key->entry map: duplicate keys (a user error, but
1497
+ // one that must degrade instead of corrupt) consume entries in order of
1498
+ // appearance, so no entry is ever matched twice - matching one twice is
1499
+ // how a reused row got disposed and a removed one resurrected
1500
+ const previous = new Map<any, EachEntry[]>()
1501
+ entries.forEach(entry => {
1502
+ const bucket = previous.get(entry.key)
1503
+ if (bucket) bucket.push(entry)
1504
+ else previous.set(entry.key, [entry])
1505
+ })
1358
1506
 
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
- }
1507
+ const seen = new Set<any>()
1508
+ 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
1515
+ if (seen.has(key) && !warnedDuplicates) {
1516
+ warnedDuplicates = true
1517
+ console.warn(`jq79: duplicate :key in :each "${node.attrs[":each"]}"; duplicates pair up by position`)
1518
+ }
1519
+ seen.add(key)
1520
+ const existing = previous.get(key)?.shift()
1521
+
1522
+ 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
1527
+ }
1380
1528
 
1381
- if (existing) {
1382
- existing.fx.dispose()
1383
- removeRange(existing.range)
1384
- }
1529
+ if (existing) {
1530
+ existing.fx.dispose()
1531
+ removeRange(existing.range)
1532
+ }
1385
1533
 
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
- })
1534
+ const itemFx = createEffectScope(scope)
1535
+ // bounds captured before the positioning pass inserts the entry: a
1536
+ // component entry is a fragment, which empties on insertion (see boundsOf)
1537
+ const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
1538
+ return { key, item, scope: itemScope, fx: itemFx, range }
1539
+ })
1392
1540
 
1393
- // whatever no new item consumed is gone
1394
- previous.forEach(bucket => bucket.forEach(entry => {
1395
- entry.fx.dispose()
1396
- removeRange(entry.range)
1397
- }))
1541
+ // whatever no new item consumed is gone. Effects are torn down one by one
1542
+ // as always; the DOM goes in runs of neighbours, which is what makes
1543
+ // clearing a long list one mutation instead of one per row
1544
+ const dead = new Set<EachEntry>()
1545
+ previous.forEach(bucket => bucket.forEach(entry => dead.add(entry)))
1546
+ if (dead.size) {
1547
+ dead.forEach(entry => entry.fx.dispose())
1548
+ removeRuns(contiguousRuns(entries, entry => dead.has(entry)))
1549
+ }
1398
1550
 
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
- })
1551
+ let prevNode: Node = anchor
1552
+ nextEntries.forEach(entry => {
1553
+ if (prevNode.nextSibling !== entry.range.first) moveRangeAfter(entry.range, prevNode)
1554
+ prevNode = entry.range.last
1555
+ })
1404
1556
 
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()))
1557
+ // reused entries that changed position: their tracked bindings re-run off
1558
+ // the list notification anyway, but a binding that reads only `$index` or
1559
+ // the named key tracked nothing - refresh them so the move reaches those
1560
+ // too. Untracked, so these runs don't feed this list effect's own deps
1561
+ moved.forEach(entry => untracked(() => entry.fx.refresh()))
1410
1562
 
1411
- entries = nextEntries
1563
+ entries = nextEntries
1564
+ } finally {
1565
+ closeRenderPass(pass)
1566
+ }
1412
1567
  })
1413
1568
 
1414
1569
  return wrapper
@@ -1416,8 +1571,21 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1416
1571
 
1417
1572
  // renders a list of sibling template nodes (text + elements), grouping
1418
1573
  // 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()
1574
+ // `into` renders straight into an element that is not in the document yet -
1575
+ // what renderElement does for an element's own children. Every element used to
1576
+ // get a DocumentFragment of its own, filled and then emptied into it: for a
1577
+ // 10,000-row table that is 70,000 fragments and a second pass over every node,
1578
+ // and the intermediate is invisible either way because the element is still
1579
+ // detached. Callers that need a standalone chunk (a component's content, an
1580
+ // :if branch) omit it and get the fragment
1581
+ const renderNodes = <T extends ParentNode>(
1582
+ nodes: (TemplateNode | string)[],
1583
+ scope: Record<string, any>,
1584
+ fx: EffectScope,
1585
+ shadow = false,
1586
+ into?: T
1587
+ ): T | DocumentFragment => {
1588
+ const fragment = into ?? document.createDocumentFragment()
1421
1589
  let i = 0
1422
1590
 
1423
1591
  while (i < nodes.length) {