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/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 +13 -13
- package/dist/jq79.js.map +1 -1
- package/package.json +2 -1
- package/src/jq79.ts +242 -48
- package/src/reactive.ts +170 -39
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
|
|
304
|
-
// write to "data"
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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
|
-
//
|
|
314
|
-
// dep's own dots rather than comparing deps against each
|
|
315
|
-
// over 10,000 rows tracks 10,000 deps, and the pairwise
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
531
|
-
const after = $toRaw(next[key])
|
|
617
|
+
difference.keys.forEach(key => {
|
|
532
618
|
const child = `${dotKey}.${key}`
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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)
|