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/src/reactive.ts CHANGED
@@ -86,6 +86,11 @@ const KEYS_SEGMENT = " keys"
86
86
 
87
87
  const keysPath = (path: string): string => (path ? `${path}.${KEYS_SEGMENT}` : KEYS_SEGMENT)
88
88
 
89
+ // an array's length, as a dep suffix: see indexable, which asks whether an
90
+ // effect holds the length of the container a slot sits in
91
+ const LENGTH_SUFFIX = ".length"
92
+
93
+
89
94
  const createTrieNode = (parent: TrieNode | null, segment: string): TrieNode =>
90
95
  ({ children: null, own: null, deep: null, parent, segment })
91
96
 
@@ -299,25 +304,71 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
299
304
  return matched
300
305
  }
301
306
 
307
+ // whether path[start..end) is an array index - all digits, so `data.6` is a
308
+ // slot and `data.tags` is not. Read off the path in place: this runs per
309
+ // ancestor of every dep an effect holds
310
+ const isSlotSegment = (path: string, start: number, end: number): boolean => {
311
+ if (start === end) return false
312
+ for (let at = start; at < end; at++) {
313
+ const code = path.charCodeAt(at)
314
+ if (code < 48 || code > 57) return false
315
+ }
316
+ return true
317
+ }
318
+
302
319
  // Reaching `data[5].label` reads three paths and tracks all three, but for an
303
- // ordinary effect the two ancestors carry no information the leaf doesn't: a
304
- // write to "data" or "data.5" reaches "data.5.label" through the subtree
305
- // sweep anyway. Dropping them is a third of the index to build, hold and tear
306
- // down on a list of any size.
320
+ // ordinary effect an ancestor carries no information the leaf doesn't: a
321
+ // write to "data" reaches "data.5.label" through the subtree sweep anyway.
322
+ // Dropping them is a third of the index to build, hold and tear down on a
323
+ // list of any size.
324
+ //
325
+ // An **array slot** is the exception, and it is the reason this isn't simply
326
+ // "every ancestor". A splice wakes the slots it shifted and deliberately
327
+ // does not sweep below them - what sits under a slot belongs to the row that
328
+ // was wrapped there, and that row did not change (see splicedAt). So the
329
+ // effect that read `data[6].label` has to keep `data.6` as a dep of its own,
330
+ // or removing a row ahead of it leaves the binding showing the old row's
331
+ // label. A row binding inside a `:each` never read the slot and so keeps
332
+ // nothing extra - it holds one dep and doesn't reach this code at all
333
+ //
334
+ // ...unless the effect also holds the container's **length**, and then the
335
+ // slots are redundant again: a splice always changes the length and
336
+ // `notifyReplaced` announces it exactly, so whoever tracked the length hears
337
+ // every shift there is without a slot dep of their own. That is not a
338
+ // detail - the `:each` list effect reads the length *and* every slot, so
339
+ // without this clause it indexes a second dep per row, and `clearLarge`
340
+ // measured +4.5% (4 of 4 rounds, ±2.7% noise) tearing them all down again
307
341
  //
308
342
  // Not for a `deep` effect, where it is exactly backwards - a forwarding
309
343
  // effect wakes off its *ancestor* entries, so its shallowest dep is the one
310
344
  // doing the work and the leaves are the redundant ones
311
345
  const indexable = (effect: Effect, deps: Set<string>): Set<string> => {
312
346
  if (effect.deep || deps.size < 2) return deps
313
- // every ancestor of every dep is redundant, so mark them by walking each
314
- // dep's own dots rather than comparing deps against each other: a `:each`
315
- // over 10,000 rows tracks 10,000 deps, and the pairwise version of this
316
- // was 100,000,000 string comparisons
347
+ // an ancestor that isn't a slot is redundant, and they are marked by
348
+ // walking each dep's own dots rather than comparing deps against each
349
+ // other: a `:each` over 10,000 rows tracks 10,000 deps, and the pairwise
350
+ // version of this was 100,000,000 string comparisons
351
+ // which containers this effect tracks the length of, resolved in one pass
352
+ // so the walk below can ask without building a `${container}.length` per
353
+ // slot it meets - a list effect meets one per row
354
+ const lengthTracked = new Set<string>()
355
+ deps.forEach(dep => { if (dep.endsWith(LENGTH_SUFFIX)) lengthTracked.add(dep.slice(0, -LENGTH_SUFFIX.length)) })
356
+
317
357
  const redundant = new Set<string>()
318
358
  deps.forEach(dep => {
359
+ let from = 0
360
+ // the ancestor one level up, carried rather than re-sliced: it is the
361
+ // container of the segment being looked at, and it was already built
362
+ let parent = ""
319
363
  for (let dot = dep.indexOf("."); dot !== -1; dot = dep.indexOf(".", dot + 1)) {
320
- redundant.add(dep.slice(0, dot))
364
+ const ancestor = dep.slice(0, dot)
365
+ // `from > 0` because a slot needs a container to be a slot of: a
366
+ // top-level numeric key is a key of the store's root object, and no
367
+ // splice can shift it
368
+ const slot = from > 0 && isSlotSegment(dep, from, dot)
369
+ if (!slot || lengthTracked.has(parent)) redundant.add(ancestor)
370
+ parent = ancestor
371
+ from = dot + 1
321
372
  }
322
373
  })
323
374
  if (!redundant.size) return deps
@@ -466,26 +517,60 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
466
517
  // replacement that ends up sweeping anyway
467
518
  const GIVE_UP: null = null
468
519
 
469
- const whatChanged = (previous: any, next: any): string[] | null => {
520
+ // `exact` marks the keys as slots whose occupant *moved* rather than values
521
+ // that changed: nothing under them is different, so the subtree each one
522
+ // would otherwise sweep must be left alone (see splicedAt)
523
+ type Difference = { keys: string[]; exact: boolean }
524
+
525
+ const NOT_SPLICED = -1
526
+
527
+ // one element inserted into or removed from an array: from the cut onwards
528
+ // every element is the very same element, one slot over. `data.filter(...)`
529
+ // - the most ordinary edit anyone makes to a list - reads as "over half the
530
+ // container differs" to the walk below, which gives up and has the whole
531
+ // subtree swept; this recognises the shift for what it is. O(n), no
532
+ // allocation, and O(1) to reject on any pair whose lengths differ by
533
+ // anything but one. See TODOS/2026-08-23.notify-a-splice.md
534
+ const splicedAt = (previous: any[], next: any[]): number => {
535
+ const grew = next.length > previous.length
536
+ const shorter = grew ? previous : next
537
+ const longer = grew ? next : previous
538
+ let start = 0
539
+ while (start < shorter.length && Object.is($toRaw(shorter[start]), $toRaw(longer[start]))) start++
540
+ for (let index = start; index < shorter.length; index++) {
541
+ if (!Object.is($toRaw(shorter[index]), $toRaw(longer[index + 1]))) return NOT_SPLICED
542
+ }
543
+ return start
544
+ }
545
+
546
+ const whatChanged = (previous: any, next: any): Difference | null => {
470
547
  if (Array.isArray(next)) {
471
548
  const before = previous.length
472
549
  const after = next.length
473
550
  // nothing on one side means nothing to reuse on the other
474
551
  if (!before || !after) return GIVE_UP
475
552
  const span = Math.max(before, after)
553
+ if (Math.abs(before - after) === 1) {
554
+ const start = splicedAt(previous, next)
555
+ if (start !== NOT_SPLICED) {
556
+ const slots: string[] = []
557
+ for (let index = start; index < span; index++) slots.push(String(index))
558
+ return { keys: slots, exact: true }
559
+ }
560
+ }
476
561
  const changed: string[] = []
477
562
  for (let index = 0; index < span; index++) {
478
563
  if (Object.is($toRaw(previous[index]), $toRaw(next[index]))) continue
479
564
  changed.push(String(index))
480
565
  if (changed.length * 2 >= span) return GIVE_UP
481
566
  }
482
- return changed
567
+ return { keys: changed, exact: false }
483
568
  }
484
569
  const keys = new Set([...Object.keys(previous), ...Object.keys(next)])
485
570
  if (!keys.size) return GIVE_UP
486
571
  const changed: string[] = []
487
572
  keys.forEach(key => { if (!Object.is($toRaw(previous[key]), $toRaw(next[key]))) changed.push(key) })
488
- return changed.length * 2 >= keys.size ? GIVE_UP : changed
573
+ return changed.length * 2 >= keys.size ? GIVE_UP : { keys: changed, exact: false }
489
574
  }
490
575
 
491
576
  // A container replaced by another container: notify the elements that
@@ -496,7 +581,9 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
496
581
  // TODOS/2026-08-23.notify-the-difference.md
497
582
  //
498
583
  // One level deep on purpose: an element that differs is a changed value, and
499
- // notifying it sweeps its own subtree, which is what a changed value deserves
584
+ // notifying it sweeps its own subtree, which is what a changed value deserves.
585
+ // A spliced element is the exception - it did not change, it moved - and its
586
+ // slots are woken without that sweep (see splicedAt)
500
587
  const notifyReplaced = (dotKey: string, previous: any, next: any, notified: any) => {
501
588
  // one write, one wake: every path below contributes to a single set that
502
589
  // runs once at the end. Notifying them one at a time re-ran an effect that
@@ -508,14 +595,14 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
508
595
  node?.deep?.forEach(effect => matched.add(effect))
509
596
  }
510
597
 
511
- const changed = whatChanged(previous, next)
598
+ const difference = whatChanged(previous, next)
512
599
  // when most of the container differs there is nothing to spare: `data = []`
513
600
  // and a wholesale replacement change every key, and reaching each one
514
601
  // through its own trie walk costs more than the single sweep it replaces.
515
602
  // whatChanged says so by giving up. The decision has to come before
516
603
  // anything is announced, or the plain notify would fire the container's
517
604
  // listeners a second time
518
- if (!changed) return notify(dotKey, notified)
605
+ if (!difference) return notify(dotKey, notified)
519
606
 
520
607
  exactListeners.get(dotKey)?.forEach(listener => listener(notified, dotKey))
521
608
  // $onAny hears the container and nothing else, exactly as it did when this
@@ -527,12 +614,22 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
527
614
  // hears that - but nothing is swept on its account
528
615
  collectExact(dotKey)
529
616
 
530
- changed.forEach(key => {
531
- const after = $toRaw(next[key])
617
+ difference.keys.forEach(key => {
532
618
  const child = `${dotKey}.${key}`
533
- const value = isWrappable(after) ? wrap(after, child) : after
534
- exactListeners.get(child)?.forEach(listener => listener(value, child))
535
- effectsFor(child).forEach(effect => matched.add(effect))
619
+ // the value is built only for a listener that asked for it: a splice
620
+ // announces every slot it shifted, and wrapping a thousand rows to hand
621
+ // them to nobody is exactly the kind of work this path exists to avoid
622
+ const listeners = exactListeners.get(child)
623
+ if (listeners) {
624
+ const after = $toRaw(next[key])
625
+ const value = isWrappable(after) ? wrap(after, child) : after
626
+ listeners.forEach(listener => listener(value, child))
627
+ }
628
+ // a shifted slot has a new occupant and nothing under it changed - the
629
+ // rows themselves are untouched - so whoever read the slot is the whole
630
+ // audience, and the bindings below it are not woken
631
+ if (difference.exact) collectExact(child)
632
+ else effectsFor(child).forEach(effect => matched.add(effect))
536
633
  })
537
634
  if (keyCount(previous) !== keyCount(next)) collectExact(keysPath(dotKey))
538
635
  // an array's length is a real dep (a `:each` reads it on its way through
@@ -595,7 +692,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
595
692
  return Reflect.has(target, key) || (typeof key === "string" && tombstones?.has(key) === true)
596
693
  },
597
694
  // reading the key set is a dependency of its own: `Object.keys(props)`,
598
- // `{...props}`, `for...in` and renderEach's `Object.entries` all care
695
+ // `{...props}`, `for...in` and renderEach's walk of a list's keys all care
599
696
  // about which keys exist, not about what any one of them holds. It used
600
697
  // to be caught only by the coarse ancestor rule, which is now gone -
601
698
  // this is the same job Svelte gives a per-object `version` signal, held
@@ -835,23 +932,57 @@ export type EffectScope = {
835
932
  // `deep` marks every effect this scope creates as forwarding a value wholesale
836
933
  // rather than reading into it - the prop-sync scope, and nothing else so far.
837
934
  // See the `deep` flag on $effect
838
- export const createEffectScope = (scope: Record<string, any>, deep = false): EffectScope => {
839
- const disposers: Unsubscribe[] = []
840
- const runs: (() => void)[] = []
841
- // whatever the scope was handed (slot content is the only thing that sets
842
- // it today): the stores this scope's effects belong to besides their own.
843
- // Left undefined when there are none, which is $effect's fast path
844
- const alsoWakenBy: Record<string, any>[] | undefined = (scope as any)[ALSO_WAKEN_BY]
845
- return {
846
- effect: run => {
847
- disposers.push(scope.$effect(run, { deep, alsoWakenBy }))
848
- runs.push(run)
849
- },
850
- onDispose: fn => { disposers.push(fn) },
851
- refresh: () => { runs.forEach(run => run()) },
852
- dispose: () => {
853
- disposers.splice(0).forEach(dispose => dispose())
854
- runs.length = 0
855
- },
935
+ //
936
+ // A class rather than an object of closures, and its arrays built on demand,
937
+ // because a :each makes one of these *per row*: the closure form allocated the
938
+ // two arrays and four closures for every one of them, and a 10,000-row table
939
+ // is 70,000 objects that exist to hold, on the common path, three disposers.
940
+ // The prototype's methods are shared, and a row that registers nothing (a
941
+ // static template) now allocates one object and no arrays at all.
942
+ // See TODOS/2026-08-23.where-the-create-time-goes.md
943
+ //
944
+ // Prototype methods need their receiver: a caller that hands one on as a bare
945
+ // function (`runSetupScript(..., fx.effect, ...)` did) must wrap it instead
946
+ // (`run => fx.effect(run)`). Both such call sites are in renderComponent
947
+ class Scope implements EffectScope {
948
+ // built on first use: `runs` in particular is only ever read by refresh(),
949
+ // which only a :each whose template names a position ever calls
950
+ private disposers: Unsubscribe[] | null = null
951
+ private runs: (() => void)[] | null = null
952
+ // one options object for the whole scope instead of one per effect - $effect
953
+ // destructures it on entry and keeps nothing. Left undefined on the common
954
+ // path, which is $effect's own fast path
955
+ private options: EffectOptions | undefined
956
+
957
+ constructor(private scope: Record<string, any>, deep: boolean) {
958
+ // whatever the scope was handed (slot content is the only thing that sets
959
+ // it today): the stores this scope's effects belong to besides their own
960
+ const alsoWakenBy: Record<string, any>[] | undefined = (scope as any)[ALSO_WAKEN_BY]
961
+ this.options = deep || alsoWakenBy ? { deep, alsoWakenBy } : undefined
962
+ }
963
+
964
+ effect(run: () => void) {
965
+ ;(this.disposers ??= []).push(this.scope.$effect(run, this.options))
966
+ ;(this.runs ??= []).push(run)
967
+ }
968
+
969
+ onDispose(fn: Unsubscribe) {
970
+ ;(this.disposers ??= []).push(fn)
971
+ }
972
+
973
+ refresh() {
974
+ this.runs?.forEach(run => run())
975
+ }
976
+
977
+ dispose() {
978
+ // detached before draining, not copied out of the way with splice(0): a
979
+ // disposer that registers another one is as lost either way, and the copy
980
+ // was an array per row on the teardown path
981
+ const disposers = this.disposers
982
+ this.disposers = null
983
+ this.runs = null
984
+ if (disposers) for (let i = 0; i < disposers.length; i++) disposers[i]()
856
985
  }
857
986
  }
987
+
988
+ export const createEffectScope = (scope: Record<string, any>, deep = false): EffectScope => new Scope(scope, deep)