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/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/dist/reactive.d.ts +6 -2
- package/package.json +2 -1
- package/src/jq79.ts +238 -70
- package/src/reactive.ts +467 -27
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) },
|