jq79 0.6.1 → 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 +98 -25
- package/src/reactive.ts +169 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jq79",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"reactive",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"site": "node scripts/build-site.mjs",
|
|
66
66
|
"site.dev": "node scripts/site-dev.mjs",
|
|
67
67
|
"benchmark": "node scripts/run-benchmark.mjs",
|
|
68
|
+
"benchmark:ab": "node scripts/run-ab.mjs",
|
|
68
69
|
"benchmark:comparison": "node scripts/run-comparison.mjs",
|
|
69
70
|
"prepublishOnly": "npm test && npm run build"
|
|
70
71
|
},
|
package/src/jq79.ts
CHANGED
|
@@ -267,8 +267,49 @@ const evalHandler = (expr: string, scope: Record<string, any>, extras: Record<st
|
|
|
267
267
|
|
|
268
268
|
// [\s\S] rather than `.` so an expression can span lines, like the ones in
|
|
269
269
|
// directive attributes (which reach evalExpr wrapped in parens either way)
|
|
270
|
-
const
|
|
271
|
-
|
|
270
|
+
const INTERPOLATION_RE = /{{\s*([\s\S]+?)\s*}}/g
|
|
271
|
+
|
|
272
|
+
// A text template split once into its literal and expression parts. The split
|
|
273
|
+
// used to happen on every run of every instance - `String.replace` over the
|
|
274
|
+
// whole text, a fresh match object and a callback per expression - and a
|
|
275
|
+
// :each over 1,000 rows runs it 1,000 times per text node to reach the same
|
|
276
|
+
// answer about the same string. Keyed by the template text, like compileExpr's
|
|
277
|
+
// cache and bounded the same way: by how many distinct texts the source holds
|
|
278
|
+
//
|
|
279
|
+
// An expression part is boxed so a literal `"x"` and an expression `x` stay
|
|
280
|
+
// distinguishable without a second array
|
|
281
|
+
type TextPart = string | { expr: string }
|
|
282
|
+
|
|
283
|
+
const textParts = new Map<string, TextPart[]>()
|
|
284
|
+
|
|
285
|
+
const splitText = (template: string): TextPart[] => {
|
|
286
|
+
const cached = textParts.get(template)
|
|
287
|
+
if (cached) return cached
|
|
288
|
+
const parts: TextPart[] = []
|
|
289
|
+
let at = 0
|
|
290
|
+
INTERPOLATION_RE.lastIndex = 0
|
|
291
|
+
for (let match = INTERPOLATION_RE.exec(template); match; match = INTERPOLATION_RE.exec(template)) {
|
|
292
|
+
if (match.index > at) parts.push(template.slice(at, match.index))
|
|
293
|
+
parts.push({ expr: match[1] })
|
|
294
|
+
at = match.index + match[0].length
|
|
295
|
+
}
|
|
296
|
+
if (at < template.length) parts.push(template.slice(at))
|
|
297
|
+
textParts.set(template, parts)
|
|
298
|
+
return parts
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// what an interpolated text node renders to, from the parts. `?? ""` on each
|
|
302
|
+
// expression, and String() over the join, is what template.replace did: a
|
|
303
|
+
// nullish value contributes nothing and everything else is coerced
|
|
304
|
+
const renderText = (parts: TextPart[], scope: Record<string, any>): string => {
|
|
305
|
+
if (parts.length === 1) {
|
|
306
|
+
const only = parts[0]
|
|
307
|
+
return typeof only === "string" ? only : String(evalExpr(only.expr, scope) ?? "")
|
|
308
|
+
}
|
|
309
|
+
let out = ""
|
|
310
|
+
for (const part of parts) out += typeof part === "string" ? part : String(evalExpr(part.expr, scope) ?? "")
|
|
311
|
+
return out
|
|
312
|
+
}
|
|
272
313
|
|
|
273
314
|
|
|
274
315
|
const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
|
|
@@ -378,16 +419,16 @@ const removeRange = ({ first, last }: NodeRange) => {
|
|
|
378
419
|
// single span: unlinking 10,000 rows one at a time is 40% of that operation,
|
|
379
420
|
// profiled - see TODOS/2026-08-23.batch-range-removal.md. Runs are built by the
|
|
380
421
|
// caller, which is the only place that knows what else is going
|
|
381
|
-
const removeRuns = (runs: NodeRange[]
|
|
422
|
+
const removeRuns = (runs: NodeRange[]) => {
|
|
382
423
|
runs.forEach(run => {
|
|
383
|
-
if (run.
|
|
384
|
-
const parent = run
|
|
424
|
+
if (run.first === run.last) return removeRange(run)
|
|
425
|
+
const parent = run.first.parentNode
|
|
385
426
|
if (!parent) return
|
|
386
427
|
// both ends sit between nodes, so nothing is partially selected and whole
|
|
387
428
|
// nodes are what gets unlinked
|
|
388
429
|
const range = document.createRange()
|
|
389
|
-
range.setStartBefore(run
|
|
390
|
-
range.setEndAfter(run
|
|
430
|
+
range.setStartBefore(run.first)
|
|
431
|
+
range.setEndAfter(run.last)
|
|
391
432
|
range.deleteContents()
|
|
392
433
|
})
|
|
393
434
|
}
|
|
@@ -396,16 +437,19 @@ const removeRuns = (runs: NodeRange[][]) => {
|
|
|
396
437
|
// `ordered` (which is in DOM order). Adjacency is confirmed rather than assumed:
|
|
397
438
|
// a gap - an entry removed earlier in the same pass - starts a new run, so a
|
|
398
439
|
// live entry can never end up inside one
|
|
399
|
-
const contiguousRuns = <T extends { range: NodeRange }>(ordered: T[], isDead: (entry: T) => boolean): NodeRange[]
|
|
400
|
-
const runs: NodeRange[]
|
|
401
|
-
let run: NodeRange
|
|
440
|
+
const contiguousRuns = <T extends { range: NodeRange }>(ordered: T[], isDead: (entry: T) => boolean): NodeRange[] => {
|
|
441
|
+
const runs: NodeRange[] = []
|
|
442
|
+
let run: NodeRange | null = null
|
|
402
443
|
ordered.forEach(entry => {
|
|
403
444
|
if (!isDead(entry)) {
|
|
404
445
|
run = null
|
|
405
446
|
return
|
|
406
447
|
}
|
|
407
|
-
|
|
408
|
-
|
|
448
|
+
// a run is a span, not the list of ranges inside it: only its two ends are
|
|
449
|
+
// ever read, and a list dropping 10,000 rows was building an array of
|
|
450
|
+
// 10,000 entries to hand over two of them
|
|
451
|
+
if (run && run.last.nextSibling === entry.range.first) run.last = entry.range.last
|
|
452
|
+
else runs.push((run = { first: entry.range.first, last: entry.range.last }))
|
|
409
453
|
})
|
|
410
454
|
return runs
|
|
411
455
|
}
|
|
@@ -1218,7 +1262,14 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1218
1262
|
})
|
|
1219
1263
|
}
|
|
1220
1264
|
|
|
1221
|
-
Object.entries(
|
|
1265
|
+
// walked with `for...in` rather than Object.entries().forEach: a 1,000-row
|
|
1266
|
+
// :each renders every element of its template a thousand times, and the
|
|
1267
|
+
// entries form allocates one array of pairs plus one two-element array per
|
|
1268
|
+
// attribute *per instance*. Nothing here reads the pairs as pairs, so the
|
|
1269
|
+
// allocation buys nothing and the garbage it makes is measurable - see
|
|
1270
|
+
// TODOS/2026-08-23.where-the-create-time-goes.md
|
|
1271
|
+
for (const key in node.attrs) {
|
|
1272
|
+
const value = node.attrs[key]
|
|
1222
1273
|
if (key.startsWith("@")) bindEvent(el, key, value, scope)
|
|
1223
1274
|
else if (key === ":model" || key.startsWith(":model.")) {
|
|
1224
1275
|
// :model binds component tags only (see TODOS/2026-07-15.model-directive.md;
|
|
@@ -1249,7 +1300,7 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1249
1300
|
fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
|
|
1250
1301
|
}
|
|
1251
1302
|
} else el.setAttribute(key, value)
|
|
1252
|
-
}
|
|
1303
|
+
}
|
|
1253
1304
|
|
|
1254
1305
|
const bindExpr = node.attrs[":attrs"]
|
|
1255
1306
|
if (bindExpr !== undefined) {
|
|
@@ -1271,17 +1322,23 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1271
1322
|
// static list survives every re-run, even when the expression names one of
|
|
1272
1323
|
// its classes and then drops it (class="btn" :class="{ btn: cond }" keeps
|
|
1273
1324
|
// btn on false)
|
|
1325
|
+
//
|
|
1326
|
+
// The toggle list stays null until a `:class.` attribute is actually found,
|
|
1327
|
+
// for the reason the attribute walk above is a `for...in`: entries + filter +
|
|
1328
|
+
// map allocated three arrays for every element rendered, and the
|
|
1329
|
+
// overwhelming majority of elements carry no `:class.` at all
|
|
1274
1330
|
const classExpr = node.attrs[":class"]
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
.
|
|
1278
|
-
|
|
1331
|
+
let classToggles: [string, string][] | null = null
|
|
1332
|
+
for (const key in node.attrs) {
|
|
1333
|
+
if (key.startsWith(":class.")) (classToggles ??= []).push([key.slice(":class.".length), node.attrs[key]])
|
|
1334
|
+
}
|
|
1335
|
+
if (classExpr !== undefined || classToggles) {
|
|
1279
1336
|
const staticClasses = new Set(classNames(node.attrs.class ?? ""))
|
|
1280
1337
|
let bound: string[] = []
|
|
1281
1338
|
|
|
1282
1339
|
fx.effect(() => {
|
|
1283
1340
|
const next = classExpr !== undefined ? classNames(evalExpr(classExpr, scope)) : []
|
|
1284
|
-
classToggles
|
|
1341
|
+
classToggles?.forEach(([name, expr]) => {
|
|
1285
1342
|
if (evalExpr(expr, scope)) next.push(...classNames(name))
|
|
1286
1343
|
})
|
|
1287
1344
|
bound.forEach(name => {
|
|
@@ -1404,7 +1461,10 @@ const defineScopeVar = (scope: Record<string, any>, key: string, value: any) =>
|
|
|
1404
1461
|
// `pos` is where the entry sat in the previous pass, refreshed as the buckets
|
|
1405
1462
|
// are built - the positioning pass needs the old order to work out which rows
|
|
1406
1463
|
// are already where they belong (see longestIncreasingRun)
|
|
1407
|
-
|
|
1464
|
+
// `dead` is set by the pass that disposes the entry, and read by the run walk
|
|
1465
|
+
// right after: a Set membership test per row was the alternative, and a list
|
|
1466
|
+
// dropping 10,000 rows does 10,000 of them
|
|
1467
|
+
type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope; pos: number; dead?: boolean }
|
|
1408
1468
|
|
|
1409
1469
|
// the indices of one longest strictly increasing subsequence of `positions`,
|
|
1410
1470
|
// as a flag per index. Fed the old position of every entry in the new order
|
|
@@ -1661,8 +1721,11 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1661
1721
|
const dead = new Set<EachEntry>()
|
|
1662
1722
|
previous.forEach(bucket => bucket.forEach(entry => dead.add(entry)))
|
|
1663
1723
|
if (dead.size) {
|
|
1664
|
-
dead.forEach(entry =>
|
|
1665
|
-
|
|
1724
|
+
dead.forEach(entry => {
|
|
1725
|
+
entry.dead = true
|
|
1726
|
+
entry.fx.dispose()
|
|
1727
|
+
})
|
|
1728
|
+
removeRuns(contiguousRuns(entries, entry => entry.dead === true))
|
|
1666
1729
|
}
|
|
1667
1730
|
|
|
1668
1731
|
let prevNode: Node = anchor
|
|
@@ -1716,7 +1779,17 @@ const renderNodes = <T extends ParentNode>(
|
|
|
1716
1779
|
const textNode = document.createTextNode(node)
|
|
1717
1780
|
// static text is most of a template (all of its indentation, for a start):
|
|
1718
1781
|
// only text with a {{ expression }} in it needs an effect to stay in sync
|
|
1719
|
-
|
|
1782
|
+
// the write is guarded: an effect woken by a sibling's change (one entry
|
|
1783
|
+
// of a :each refreshed on a move, a grouped notification) recomputes the
|
|
1784
|
+
// same string it already wrote, and assigning it back is a DOM mutation
|
|
1785
|
+
// the browser has to take seriously
|
|
1786
|
+
if (node.includes("{{")) {
|
|
1787
|
+
const parts = splitText(node)
|
|
1788
|
+
fx.effect(() => {
|
|
1789
|
+
const text = renderText(parts, scope)
|
|
1790
|
+
if (textNode.textContent !== text) textNode.textContent = text
|
|
1791
|
+
})
|
|
1792
|
+
}
|
|
1720
1793
|
fragment.appendChild(textNode)
|
|
1721
1794
|
i++
|
|
1722
1795
|
continue
|
|
@@ -3050,7 +3123,7 @@ export class Component79 {
|
|
|
3050
3123
|
}
|
|
3051
3124
|
declareProps(store, parseFactoryProps(script.content))
|
|
3052
3125
|
const body = deferred ? defer(factoryCode) : factoryCode
|
|
3053
|
-
return runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
3126
|
+
return runFactoryScript(body, store, run => fx.effect(run), instanceHelpers, $import, at)
|
|
3054
3127
|
}
|
|
3055
3128
|
const { vars, code } = transformSetupScript(script.content)
|
|
3056
3129
|
declareProps(store, setupSignature(script))
|
|
@@ -3058,7 +3131,7 @@ export class Component79 {
|
|
|
3058
3131
|
// to them (and reads of them) through the reactive proxy
|
|
3059
3132
|
vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
|
|
3060
3133
|
const body = deferred ? defer(code) : code
|
|
3061
|
-
return runSetupScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
3134
|
+
return runSetupScript(body, store, run => fx.effect(run), instanceHelpers, $import, at)
|
|
3062
3135
|
})()
|
|
3063
3136
|
// a script that threw has nothing left to contribute, so its rejection
|
|
3064
3137
|
// releases the gate exactly as completion does - the error is already
|
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
|
|
@@ -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)
|