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.
package/src/reactive.ts CHANGED
@@ -7,14 +7,27 @@ type AnyChangeListener = (dotKey: string, value: any) => void
7
7
  type ListenerOptions = { immediate?: boolean }
8
8
  type Unsubscribe = () => void
9
9
 
10
+ export type EffectOptions = {
11
+ // wake this effect for writes below its dependencies, not only on them
12
+ deep?: boolean
13
+ // internal: the extra stores this effect must also be registered with, so a
14
+ // change in any of them wakes it (see ATTACH). Slot content is the only
15
+ // thing that sets it, by way of createEffectScope - it is deliberately not
16
+ // part of what this API tells users about
17
+ alsoWakenBy?: Record<string, any>[]
18
+ }
19
+
10
20
  export type ReactiveDeepData<T> = T & {
11
21
  $on: (dotKey: string, listener: ChangeListener, options?: ListenerOptions) => Unsubscribe
12
22
  $onAny: (listener: AnyChangeListener, options?: ListenerOptions) => Unsubscribe
13
23
  // runs `run` immediately, recording every dotKey it reads off this store, then
14
- // re-runs it whenever a changed dotKey overlaps one of those - see pathsOverlap.
15
- // `alsoWakenBy` registers the same effect with other stores as well, so a
16
- // change in any of them wakes it too (see ATTACH)
17
- $effect: (run: () => void, alsoWakenBy?: Record<string, any>[]) => Unsubscribe
24
+ // re-runs it whenever a changed dotKey overlaps one of those - see TrieNode.
25
+ //
26
+ // `deep` also wakes it for writes *below* what it read. It is for the one
27
+ // shape the store cannot see into: an effect that hands a value to code
28
+ // outside its view - a chart library, a canvas, a request - and so reads
29
+ // nothing the proxy can record (see docs/reactive-data.md)
30
+ $effect: (run: () => void, options?: EffectOptions) => Unsubscribe
18
31
  // drops this store's subscriptions to the stores nested inside it (see
19
32
  // bridge). A store that outlives the one holding it - the shared-state case -
20
33
  // would otherwise keep the dead holder's listeners on its own list forever
@@ -41,11 +54,43 @@ const walkLeaves = (obj: Record<string, any>, path: string, visit: (dotKey: stri
41
54
  })
42
55
  }
43
56
 
44
- // true when `a` and `b` sit on the same ancestor/descendant line, e.g.
45
- // "user" & "user.address.city" (a change to either affects the other) - false
46
- // for siblings like "user.name" & "user.age"
47
- const pathsOverlap = (a: string, b: string): boolean =>
48
- a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`)
57
+ // Effect deps live in a trie keyed by path segment: a write walks down its own
58
+ // segments, so the nodes it passes through are its ancestors and the subtree it
59
+ // lands on is its descendants, with no comparison against unrelated effects.
60
+ // `own` holds effects depending on this node's exact path; `deep` holds the ones
61
+ // that want everything under it too (see the `deep` flag on $effect).
62
+ //
63
+ // Which of the two directions actually wakes an effect is the thing that makes
64
+ // this fast, and it is not symmetric - see effectsFor and
65
+ // TODOS/2026-08-23.narrow-the-wake-rule.md
66
+ //
67
+ // `children`/`own`/`deep` are allocated on first use and `parent`/`segment` let
68
+ // a removal walk back up without re-splitting the path: a 10,000-row table is
69
+ // ~40,000 nodes, and three eager allocations each (a Map and two Sets, almost
70
+ // all of them staying empty) cost more than the index saves
71
+ type TrieNode = {
72
+ children: Map<string, TrieNode> | null
73
+ own: Set<Effect> | null
74
+ deep: Set<Effect> | null
75
+ parent: TrieNode | null
76
+ segment: string
77
+ }
78
+
79
+ // the dep an `ownKeys` read records, as a reserved last segment on the
80
+ // enumerated object's own path: an ordinary trie child that no walk for a real
81
+ // key can reach, and that the subtree sweep still reaches when the whole object
82
+ // is replaced - which is correct, a new object is a new key set. A real
83
+ // property named " keys" collides with it, the same way a flat key containing
84
+ // a dot collides with the nested path of the same name (tests/reactive.test.ts)
85
+ const KEYS_SEGMENT = " keys"
86
+
87
+ const keysPath = (path: string): string => (path ? `${path}.${KEYS_SEGMENT}` : KEYS_SEGMENT)
88
+
89
+ const createTrieNode = (parent: TrieNode | null, segment: string): TrieNode =>
90
+ ({ children: null, own: null, deep: null, parent, segment })
91
+
92
+ const isEmptyNode = (node: TrieNode): boolean =>
93
+ !node.own?.size && !node.deep?.size && !node.children?.size
49
94
 
50
95
  // reads the raw object behind a store proxy. Module-level (not per-store) so a
51
96
  // value that is already reactive - in this store or in another one - can be
@@ -90,7 +135,26 @@ export const untracked = <T>(fn: () => T): T => {
90
135
  }
91
136
  }
92
137
 
93
- type Effect = { deps: Set<string>; run: () => void }
138
+ // `reindex` holds one callback per store this effect is registered with (its
139
+ // own, plus any it was attached to), each keeping that store's trie in step
140
+ // with the deps of the last settled run. It lives on the effect rather than
141
+ // in a per-store map because `run` has to reach it without a lookup
142
+ type Effect = { deps: Set<string>; run: () => void; reindex: Set<(deps: Set<string>) => void>; deep: boolean; order: number }
143
+
144
+ // creation order, module-wide. The flat `effects` set used to give this for
145
+ // free - iterating it ran effects oldest-first, so a parent's bindings always
146
+ // went before those of a child it had rendered. Matching through the trie
147
+ // returns them in walk order instead, and the tutorial's setup scripts are
148
+ // sensitive to it (a child effect running before its parent's re-sync reads
149
+ // state the parent has not written yet). Effects are ordered explicitly rather
150
+ // than left to whatever the index happens to yield
151
+ let effectsCreated = 0
152
+
153
+ // the deps an effect has before its first run, and what indexEffect compares
154
+ // its first run against. Never written to - every run installs a fresh set -
155
+ // so one frozen instance stands in for the two empty sets each effect used to
156
+ // allocate. 30,000 effects is 60,000 of them
157
+ const NO_DEPS: ReadonlySet<string> = new Set()
94
158
 
95
159
  // an effect lives in exactly one store's `effects` set - the one whose
96
160
  // $effect created it - and only that store's notify walks it. Content that
@@ -126,7 +190,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
126
190
  // the same proxy for the same item or every row would re-render. The flip
127
191
  // side is that an object's path is fixed when it is first wrapped, so after a
128
192
  // reorder its notifications carry the old index - effects that read the list
129
- // itself still wake up (pathsOverlap), which is what makes it a non-issue in
193
+ // itself still wake up (they hold its ancestor as a dep), which is what makes it a non-issue in
130
194
  // practice
131
195
  const proxies = new WeakMap<object, Record<string, any>>()
132
196
 
@@ -136,15 +200,351 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
136
200
  // handles. Null-prototype, so `key in storeApi` can't match Object.prototype
137
201
  const storeApi: Record<string, any> = Object.create(null)
138
202
 
203
+ // this store's dep index (see TrieNode). Segments come from splitting a
204
+ // dotKey on ".", which is also why a flat key written as "a.b" indexes
205
+ // exactly where the nested a.b lives - the collision dot-paths have always
206
+ // had, preserved rather than special-cased
207
+ const depTrie = createTrieNode(null, "")
208
+
209
+ // hands back the node it registered on, which is what lets a removal skip the
210
+ // path entirely (see indexEffect)
211
+ const insertDep = (dep: string, effect: Effect): TrieNode => {
212
+ let node = depTrie
213
+ dep.split(".").forEach(segment => {
214
+ const children = (node.children ??= new Map())
215
+ let child = children.get(segment)
216
+ if (!child) {
217
+ child = createTrieNode(node, segment)
218
+ children.set(segment, child)
219
+ }
220
+ node = child
221
+ })
222
+ ;((effect.deep ? (node.deep ??= new Set()) : (node.own ??= new Set()))).add(effect)
223
+ return node
224
+ }
225
+
226
+ // prunes the nodes it empties on the way back up, so a list that churns
227
+ // through rows doesn't leave the trie growing over the dead ones. Follows
228
+ // `parent` rather than re-walking from the root: tearing down a 10,000-row
229
+ // table is 30,000 of these, and splitting each path again to find a node the
230
+ // caller was already holding is most of what that used to cost
231
+ const removeDep = (node: TrieNode, effect: Effect) => {
232
+ ;(effect.deep ? node.deep : node.own)?.delete(effect)
233
+ let current: TrieNode | null = node
234
+ while (current?.parent && isEmptyNode(current)) {
235
+ current.parent.children!.delete(current.segment)
236
+ current = current.parent
237
+ }
238
+ }
239
+
240
+ const nodeAt = (dep: string): TrieNode | undefined => {
241
+ let node: TrieNode | undefined = depTrie
242
+ for (const segment of dep.split(".")) {
243
+ node = node.children?.get(segment)
244
+ if (!node) return undefined
245
+ }
246
+ return node
247
+ }
248
+
249
+ // every effect this write concerns. Two directions, and they are not
250
+ // symmetric:
251
+ //
252
+ // - **downwards**, always: whatever hangs off the node the walk lands on. A
253
+ // dep of "user.name" hears `user = {...}`, because replacing the object
254
+ // replaced the name with it.
255
+ // - **upwards**, only where an ancestor dep is the only channel a change
256
+ // has (see `coarsePath`). A dep of "data" does NOT hear
257
+ // `data[5].label = x`: an effect that read the array on its way to row
258
+ // 7's label has no stake in row 5's, and waking all of them is what made
259
+ // 100 row writes cost 100,000 effect runs - see
260
+ // TODOS/2026-08-23.narrow-the-wake-rule.md
261
+ //
262
+ // Returned as a snapshot, so the effects it wakes can reindex themselves -
263
+ // or dispose each other - while it drains
264
+ const effectsFor = (dotKey: string): Set<Effect> => {
265
+ const matched = new Set<Effect>()
266
+ const sweep = (from: TrieNode) => {
267
+ const pending = from.children ? [...from.children.values()] : []
268
+ while (pending.length) {
269
+ const next = pending.pop()!
270
+ next.own?.forEach(effect => matched.add(effect))
271
+ next.deep?.forEach(effect => matched.add(effect))
272
+ next.children?.forEach(child => pending.push(child))
273
+ }
274
+ }
275
+
276
+ let node: TrieNode | undefined = depTrie
277
+ const segments = dotKey.split(".")
278
+ let path = ""
279
+ for (let depth = 0; depth < segments.length - 1; depth++) {
280
+ node = node.children?.get(segments[depth])
281
+ if (!node) return matched
282
+ path = path ? `${path}.${segments[depth]}` : segments[depth]
283
+ node.deep?.forEach(effect => matched.add(effect))
284
+ // a nested store sits here: an effect that read through it holds this
285
+ // path and nothing below it, so its own set is the whole channel
286
+ if (bridges.has(path)) node.own?.forEach(effect => matched.add(effect))
287
+ // ...whereas an array's length stands for the array: everything that
288
+ // read an element has to hear a truncation, and those deps are below
289
+ if (depth === segments.length - 2 && segments[depth + 1] === "length") {
290
+ node.own?.forEach(effect => matched.add(effect))
291
+ sweep(node)
292
+ }
293
+ }
294
+ node = node.children?.get(segments[segments.length - 1])
295
+ if (!node) return matched
296
+ node.own?.forEach(effect => matched.add(effect))
297
+ node.deep?.forEach(effect => matched.add(effect))
298
+ sweep(node)
299
+ return matched
300
+ }
301
+
302
+ // 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.
307
+ //
308
+ // Not for a `deep` effect, where it is exactly backwards - a forwarding
309
+ // effect wakes off its *ancestor* entries, so its shallowest dep is the one
310
+ // doing the work and the leaves are the redundant ones
311
+ const indexable = (effect: Effect, deps: Set<string>): Set<string> => {
312
+ 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
317
+ const redundant = new Set<string>()
318
+ deps.forEach(dep => {
319
+ for (let dot = dep.indexOf("."); dot !== -1; dot = dep.indexOf(".", dot + 1)) {
320
+ redundant.add(dep.slice(0, dot))
321
+ }
322
+ })
323
+ if (!redundant.size) return deps
324
+ const kept = new Set<string>()
325
+ deps.forEach(dep => { if (!redundant.has(dep)) kept.add(dep) })
326
+ return kept
327
+ }
328
+
329
+ // registers `effect` with this store's trie and keeps it in step. Each store
330
+ // tracks what it indexed for the effect separately, because a detach must
331
+ // clear this trie without touching the others
332
+ const indexEffect = (effect: Effect): Unsubscribe => {
333
+ // dep -> the node it sits on, so removal never re-walks a path: the
334
+ // overwhelmingly common re-run has identical deps and touches the trie not
335
+ // at all, and a disposal goes straight to the nodes it registered
336
+ // almost every effect ends up with exactly one dep once the redundant
337
+ // ancestors are pruned - a row binding reads one path - so the single case
338
+ // is held in two slots and the Map is allocated only when a second arrives
339
+ let soleDep: string | null = null
340
+ let soleNode: TrieNode | null = null
341
+ let indexed: Map<string, TrieNode> | null = null
342
+ // what the last run tracked, before pruning. The comparison has to happen
343
+ // against these rather than against what is indexed, because pruning them
344
+ // is itself work this fast path exists to skip
345
+ let lastTracked: ReadonlySet<string> = NO_DEPS
346
+
347
+ const placed = (dep: string): boolean =>
348
+ indexed ? indexed.has(dep) : soleDep === dep
349
+
350
+ const place = (dep: string) => {
351
+ const node = insertDep(dep, effect)
352
+ if (indexed) indexed.set(dep, node)
353
+ else if (soleDep === null) { soleDep = dep; soleNode = node }
354
+ else {
355
+ indexed = new Map([[soleDep, soleNode!], [dep, node]])
356
+ soleDep = soleNode = null
357
+ }
358
+ }
359
+
360
+ const unplaceStale = (deps: Set<string>) => {
361
+ if (indexed) {
362
+ indexed.forEach((node, dep) => {
363
+ if (deps.has(dep)) return
364
+ removeDep(node, effect)
365
+ indexed!.delete(dep)
366
+ })
367
+ return
368
+ }
369
+ if (soleDep !== null && !deps.has(soleDep)) {
370
+ removeDep(soleNode!, effect)
371
+ soleDep = soleNode = null
372
+ }
373
+ }
374
+
375
+ const unplaceAll = () => {
376
+ if (indexed) indexed.forEach(node => removeDep(node, effect))
377
+ else if (soleNode) removeDep(soleNode, effect)
378
+ indexed = null
379
+ soleDep = soleNode = null
380
+ }
381
+ const sync = (tracked: Set<string>) => {
382
+ // an effect that re-runs almost always reads exactly what it read last
383
+ // time, and this is on the path of every single run: same count and every
384
+ // path seen before means nothing about the index can have changed
385
+ if (tracked.size === lastTracked.size) {
386
+ let unchanged = true
387
+ tracked.forEach(dep => { unchanged &&= lastTracked.has(dep) })
388
+ if (unchanged) return
389
+ }
390
+ lastTracked = tracked
391
+ const deps = indexable(effect, tracked)
392
+ unplaceStale(deps)
393
+ deps.forEach(dep => { if (!placed(dep)) place(dep) })
394
+ }
395
+ effect.reindex.add(sync)
396
+ sync(effect.deps) // an effect attached after it first ran arrives with deps
397
+ return () => {
398
+ effect.reindex.delete(sync)
399
+ unplaceAll()
400
+ lastTracked = NO_DEPS
401
+ }
402
+ }
403
+
404
+ // wakes the effects sitting on one exact path, without the subtree sweep a
405
+ // full notify does. Two callers want this: a key-set change (nothing under
406
+ // the object changed, only which keys it has) and a container replaced by one
407
+ // holding the same elements (see notifyReplaced)
408
+ const wakeExactly = (dep: string) => {
409
+ const node = nodeAt(dep)
410
+ if (node) runMatched(new Set([...(node.own ?? []), ...(node.deep ?? [])]))
411
+ }
412
+
413
+ // an object's key set changed. Effects only - a key set isn't a value, so
414
+ // there is nothing to hand $on/$onAny that they don't already get from the
415
+ // key's own notification
416
+ const notifyKeys = (path: string) => wakeExactly(keysPath(path))
417
+
418
+ // oldest first, and re-checking membership as it goes: an effect disposed by
419
+ // an earlier one in this same pass (a list diff tearing down the rows it just
420
+ // woke) must not run
421
+ const runMatched = (matched: Set<Effect>) => {
422
+ const ordered = Array.from(matched)
423
+ // effects are usually collected in creation order already - one node's set
424
+ // is filled as its effects are made, and a subtree sweep of a freshly-built
425
+ // list walks them the same way. Checking costs one pass; sorting a woken
426
+ // set of 10,000 costs rather more
427
+ let sorted = true
428
+ for (let i = 1; sorted && i < ordered.length; i++) sorted = ordered[i - 1].order < ordered[i].order
429
+ if (!sorted) ordered.sort((a, b) => a.order - b.order)
430
+ ordered.forEach(effect => { if (effects.has(effect)) effect.run() })
431
+ }
432
+
139
433
  const notify = (dotKey: string, value: any, isNewKey = false) => {
140
434
  exactListeners.get(dotKey)?.forEach(listener => listener(value, dotKey))
141
435
  anyListeners.forEach(listener => listener(dotKey, value))
142
- effects.forEach(effect => {
143
- // a newly-created key re-runs every effect: an effect that read the
144
- // name while it didn't exist couldn't track it (`with` skipped the
145
- // store entirely), so dep matching would never wake it up
146
- if (isNewKey || Array.from(effect.deps).some(dep => pathsOverlap(dep, dotKey))) effect.run()
436
+ // a newly-created key re-runs every effect: an effect that read the
437
+ // name while it didn't exist couldn't track it (`with` skipped the
438
+ // store entirely), so dep matching would never wake it up
439
+ if (isNewKey) effects.forEach(effect => effect.run())
440
+ // `effects.has` stands in for the membership check `effects.forEach` used
441
+ // to give for free: an effect disposed by an earlier effect in this same
442
+ // notify (a list diff tearing down the rows it just woke) must not run
443
+ else runMatched(effectsFor(dotKey))
444
+ }
445
+
446
+ // both sides are containers of the same kind, so what changed can be asked
447
+ // rather than assumed. Not the same object - that case never gets here (a
448
+ // same-reference write is the deep-touch channel and stays loud), and not a
449
+ // store on either side, which passes through whole
450
+ const replaceable = (previous: any, next: any): boolean =>
451
+ previous !== next &&
452
+ previous !== null && next !== null &&
453
+ typeof previous === "object" && typeof next === "object" &&
454
+ !isStore(previous) && !isStore(next) &&
455
+ isPlainData(previous) && isPlainData(next) &&
456
+ Array.isArray(previous) === Array.isArray(next)
457
+
458
+ const keyCount = (container: any): number =>
459
+ Array.isArray(container) ? container.length : Object.keys(container).length
460
+
461
+ // which keys hold a different value than they did, or null when so many do
462
+ // that notifying them one at a time would cost more than sweeping the
463
+ // container. Arrays - the case this exists for - are walked by index, so a
464
+ // 10,000 element list is compared without building a key array or a set of
465
+ // them: that bookkeeping alone was costing more than it saved on every
466
+ // replacement that ends up sweeping anyway
467
+ const GIVE_UP: null = null
468
+
469
+ const whatChanged = (previous: any, next: any): string[] | null => {
470
+ if (Array.isArray(next)) {
471
+ const before = previous.length
472
+ const after = next.length
473
+ // nothing on one side means nothing to reuse on the other
474
+ if (!before || !after) return GIVE_UP
475
+ const span = Math.max(before, after)
476
+ const changed: string[] = []
477
+ for (let index = 0; index < span; index++) {
478
+ if (Object.is($toRaw(previous[index]), $toRaw(next[index]))) continue
479
+ changed.push(String(index))
480
+ if (changed.length * 2 >= span) return GIVE_UP
481
+ }
482
+ return changed
483
+ }
484
+ const keys = new Set([...Object.keys(previous), ...Object.keys(next)])
485
+ if (!keys.size) return GIVE_UP
486
+ const changed: string[] = []
487
+ 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
489
+ }
490
+
491
+ // A container replaced by another container: notify the elements that
492
+ // actually differ instead of the container and everything under it.
493
+ // `data = [...data, ...more]` holds the very same row objects at every index
494
+ // it had before, so waking all thirty thousand of their bindings to re-render
495
+ // identical output is ~150ms of a 208ms append - see
496
+ // TODOS/2026-08-23.notify-the-difference.md
497
+ //
498
+ // 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
500
+ const notifyReplaced = (dotKey: string, previous: any, next: any, notified: any) => {
501
+ // one write, one wake: every path below contributes to a single set that
502
+ // runs once at the end. Notifying them one at a time re-ran an effect that
503
+ // depends on several of them once per path
504
+ const matched = new Set<Effect>()
505
+ const collectExact = (dep: string) => {
506
+ const node = nodeAt(dep)
507
+ node?.own?.forEach(effect => matched.add(effect))
508
+ node?.deep?.forEach(effect => matched.add(effect))
509
+ }
510
+
511
+ const changed = whatChanged(previous, next)
512
+ // when most of the container differs there is nothing to spare: `data = []`
513
+ // and a wholesale replacement change every key, and reaching each one
514
+ // through its own trie walk costs more than the single sweep it replaces.
515
+ // whatChanged says so by giving up. The decision has to come before
516
+ // anything is announced, or the plain notify would fire the container's
517
+ // listeners a second time
518
+ if (!changed) return notify(dotKey, notified)
519
+
520
+ exactListeners.get(dotKey)?.forEach(listener => listener(notified, dotKey))
521
+ // $onAny hears the container and nothing else, exactly as it did when this
522
+ // was one notify. It is what a bridge re-notifies upstairs, and the holder
523
+ // sweeps its own side off that one path - announcing each changed element
524
+ // as well would be a thousand redundant notifications for the same news
525
+ anyListeners.forEach(listener => listener(dotKey, notified))
526
+ // the container itself did change: whoever read it, or forwards it whole,
527
+ // hears that - but nothing is swept on its account
528
+ collectExact(dotKey)
529
+
530
+ changed.forEach(key => {
531
+ const after = $toRaw(next[key])
532
+ 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))
147
536
  })
537
+ if (keyCount(previous) !== keyCount(next)) collectExact(keysPath(dotKey))
538
+ // an array's length is a real dep (a `:each` reads it on its way through
539
+ // list.map) and is not one of the keys walked above. Collected exactly:
540
+ // the sweep a length write normally carries is for a truncation, and the
541
+ // elements that a shrink dropped are already in `keys`
542
+ if (Array.isArray(next) && previous.length !== next.length) {
543
+ const lengthKey = `${dotKey}.length`
544
+ exactListeners.get(lengthKey)?.forEach(listener => listener(next.length, lengthKey))
545
+ collectExact(lengthKey)
546
+ }
547
+ runMatched(matched)
148
548
  }
149
549
 
150
550
  const isWrappable = (value: any): value is Record<string, any> =>
@@ -156,7 +556,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
156
556
  // off a `$reactive` it was handed would never update. The holder subscribes
157
557
  // instead, and re-notifies the inner store's changes under the path it sits at
158
558
  // ("items.0" -> "cart.items.0"). An effect that read through `cart` recorded
159
- // exactly that path's ancestor as a dependency, so pathsOverlap wakes it.
559
+ // exactly that path's ancestor as a dependency, so the trie walk wakes it.
160
560
  // Chains compose: re-notifying runs this store's own $onAny listeners, which
161
561
  // is how a store two levels down still reaches the top
162
562
  const bridges = new Map<string, { store: any; unsubscribe: Unsubscribe }>()
@@ -194,6 +594,17 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
194
594
  has(target, key) {
195
595
  return Reflect.has(target, key) || (typeof key === "string" && tombstones?.has(key) === true)
196
596
  },
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
599
+ // about which keys exist, not about what any one of them holds. It used
600
+ // to be caught only by the coarse ancestor rule, which is now gone -
601
+ // this is the same job Svelte gives a per-object `version` signal, held
602
+ // as an ordinary trie child under a reserved final segment (see
603
+ // KEYS_SEGMENT), so adds and deletes wake exactly the effects enumerating
604
+ ownKeys(target) {
605
+ trackerStack[trackerStack.length - 1]?.add(keysPath(path))
606
+ return Reflect.ownKeys(target)
607
+ },
197
608
  get(target, key, receiver) {
198
609
  if (key === RAW) return target
199
610
  if (key === STORE) return path === ""
@@ -243,12 +654,17 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
243
654
  // listeners live on the child's store, not the parent's. A new key
244
655
  // always announces itself - the sweep is its whole point
245
656
  if (!isNewKey && Object.is(target[key], stored) && (stored === null || typeof stored !== "object")) return true
657
+ const previous = target[key]
246
658
  target[key] = stored
247
659
  tombstones?.delete(key) // the key exists again: no claim needed
248
660
  if (isStore(stored)) bridge(stored, dotKey)
249
661
  else unbridge(dotKey)
250
662
  const notified = isStore(stored) || !isWrappable(stored) ? stored : wrap(stored, dotKey)
251
- notify(dotKey, notified, isNewKey)
663
+ // a new key already re-runs every effect in the store (see notify), so
664
+ // the key set growing needs no announcement of its own - only a delete,
665
+ // which wakes precisely, does
666
+ if (!isNewKey && replaceable(previous, stored)) notifyReplaced(dotKey, previous, stored, notified)
667
+ else notify(dotKey, notified, isNewKey)
252
668
  return true
253
669
  },
254
670
  // `delete data.user` is a plain-object mutation like any other, so it
@@ -265,6 +681,9 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
265
681
  ;(tombstones ??= new Set()).add(key)
266
682
  unbridge(dotKey) // a nested store it held: stop listening to it
267
683
  notify(dotKey, undefined)
684
+ // the key set shrank: whoever enumerated this object hears it even
685
+ // if it never read the key that went (see the ownKeys trap)
686
+ notifyKeys(path)
268
687
  }
269
688
  return deleted
270
689
  }
@@ -299,7 +718,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
299
718
  return () => anyListeners.delete(listener)
300
719
  }
301
720
 
302
- const $effect = (run: () => void, alsoWakenBy?: Record<string, any>[]): Unsubscribe => {
721
+ const $effect = (run: () => void, { deep = false, alsoWakenBy }: EffectOptions = {}): Unsubscribe => {
303
722
  // a notify landing while this effect runs (an item's render writing to
304
723
  // the store, waking the very effect that is rendering it) must not
305
724
  // re-enter mid-run - the half-done run would race its own repeat over
@@ -310,7 +729,10 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
310
729
  let running = false
311
730
  let dirty = false
312
731
  const effect: Effect = {
313
- deps: new Set(),
732
+ deps: NO_DEPS as Set<string>,
733
+ reindex: new Set(),
734
+ deep,
735
+ order: effectsCreated++,
314
736
  run: () => {
315
737
  if (running) {
316
738
  dirty = true
@@ -335,33 +757,48 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
335
757
  if (dirty) console.error("jq79: an effect re-woke itself 100 times in a row (it writes what it reads); giving up on it settling")
336
758
  } finally {
337
759
  running = false
760
+ // the settled deps are the only ones worth indexing: the repeats of
761
+ // a dirty run overwrite each other, and a notify that lands mid-run
762
+ // is queued rather than dispatched, so nothing reads the index in
763
+ // between. In `finally` so a run that throws still leaves the index
764
+ // matching the deps the run did commit
765
+ effect.reindex.forEach(sync => sync(effect.deps))
338
766
  }
339
767
  },
340
768
  }
341
769
  effects.add(effect)
770
+ const stopIndexing = indexEffect(effect)
771
+ const forget = () => {
772
+ effects.delete(effect)
773
+ stopIndexing()
774
+ }
342
775
  // the shared case is rare (only slot content asks for it) and this
343
776
  // function is on the stack for as long as whatever it renders - a
344
777
  // component that renders itself stacks 200 of these - so it keeps the
345
778
  // shape it had, and the extra bookkeeping lives in its own frame
346
- if (alsoWakenBy?.length) return attachAndRun(effect, alsoWakenBy)
779
+ if (alsoWakenBy?.length) return attachAndRun(effect, alsoWakenBy, forget)
347
780
  effect.run()
348
- return () => { effects.delete(effect) }
781
+ return forget
349
782
  }
350
783
 
351
784
  // attached before the first run, so a store that notifies during it (a setup
352
785
  // script's write, a prop sync) reaches this effect like any other
353
- const attachAndRun = (effect: Effect, alsoWakenBy: Record<string, any>[]): Unsubscribe => {
786
+ const attachAndRun = (effect: Effect, alsoWakenBy: Record<string, any>[], forget: Unsubscribe): Unsubscribe => {
354
787
  const detach = alsoWakenBy.map(store => store?.[ATTACH]?.(effect)).filter(Boolean) as Unsubscribe[]
355
788
  effect.run()
356
789
  return () => {
357
- effects.delete(effect)
790
+ forget()
358
791
  detach.forEach(drop => drop())
359
792
  }
360
793
  }
361
794
 
362
795
  const $__attach = (effect: Effect): Unsubscribe => {
363
796
  effects.add(effect)
364
- return () => { effects.delete(effect) }
797
+ const stopIndexing = indexEffect(effect)
798
+ return () => {
799
+ effects.delete(effect)
800
+ stopIndexing()
801
+ }
365
802
  }
366
803
 
367
804
  const $dispose = () => {
@@ -395,7 +832,10 @@ export type EffectScope = {
395
832
  dispose: () => void
396
833
  }
397
834
 
398
- export const createEffectScope = (scope: Record<string, any>): EffectScope => {
835
+ // `deep` marks every effect this scope creates as forwarding a value wholesale
836
+ // rather than reading into it - the prop-sync scope, and nothing else so far.
837
+ // See the `deep` flag on $effect
838
+ export const createEffectScope = (scope: Record<string, any>, deep = false): EffectScope => {
399
839
  const disposers: Unsubscribe[] = []
400
840
  const runs: (() => void)[] = []
401
841
  // whatever the scope was handed (slot content is the only thing that sets
@@ -404,7 +844,7 @@ export const createEffectScope = (scope: Record<string, any>): EffectScope => {
404
844
  const alsoWakenBy: Record<string, any>[] | undefined = (scope as any)[ALSO_WAKEN_BY]
405
845
  return {
406
846
  effect: run => {
407
- disposers.push(scope.$effect(run, alsoWakenBy))
847
+ disposers.push(scope.$effect(run, { deep, alsoWakenBy }))
408
848
  runs.push(run)
409
849
  },
410
850
  onDispose: fn => { disposers.push(fn) },