All files / src reactive.ts

98.64% Statements 510/517
98.17% Branches 269/274
96.33% Functions 105/109
100% Lines 401/401

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063                                                                        15x 9x         15x 10406x 7612x 7612x     15x 2x 3x 3x 2x                                                                                     15x 7344x 1096x     15x 1458x 156x         15x 2235x 242x     15x 529x 97x     15x 2230x 2230x     15x 2292x 2292x                 15x   735x       15x     15x 10097x   15x 3710x             15x             15x 30336x 30336x 30336x             15x   15x 34346x         15x       15x 672x 672x 672x   672x                                 15x           15x                                 15x           15x   15x 1841x 1841x 1841x                   1841x           1841x           1841x       1841x 8785x 8785x 18796x 18796x 18796x 8256x 8256x   18796x   8785x 7328x 8785x               1841x 2758x 2230x 2758x 2758x 1947x 1947x       1841x 527x 527x 668x 668x   414x                                   1841x 2032x 2032x 793x 793x 994x 994x 994x 994x       2032x 2032x 2032x 2032x 109x 109x 90x 90x     90x     90x 1x 1x     2013x 2013x 792x 792x 792x 792x           1841x 3506x 3506x 3600x 3600x   2120x                                                         1841x 5314x               1960x 7351x   1960x 1960x 7351x     7351x 7351x 7743x       7743x 7743x 7743x 7743x     1960x 1435x 6106x 1435x           1841x             5086x 5086x 5086x       5086x   5086x 9424x   5086x 8785x 8785x 6702x   1837x 1837x       5086x 5314x 430x 1201x 580x 580x   430x   4884x 12x 12x       5086x 1351x 856x 1273x 1273x   5086x       11631x 6348x 6348x 6348x   5314x 5314x 5314x 9424x   5086x 5086x 5086x 1273x 1273x 1273x               1841x 11x 11x 5x 5x 5x 5x             1841x         1841x 2398x         2398x 2398x 2398x 2398x     1841x 3293x 3293x       3293x       2025x             1841x 2359x                         1841x                       1841x                 1841x 46x 46x 46x 46x 46x 46x 96x   24x     1841x 641x 223x 223x     223x   223x 199x 199x 46x 46x 24x 98x 24x     175x 175x 397x 174x 174x   56x   418x 418x 418x 418x 418x 729x             719x 418x                           1841x       641x 641x 516x 516x 409x 409x       641x             641x   368x         368x     368x   368x 105x       105x 105x 1x 1x 1x         105x 8x       368x         368x 24x 24x 24x   368x     1841x 23590x                     1841x   1841x 126x 126x 22x 22x   23x         1841x 3620x 3620x         1841x 9886x 9886x             5554x   5554x   9030x                   1403x 1403x     41026x 37174x 34823x 25780x   20070x 20070x       20070x 20070x 90x 90x     19980x 19980x                   4093x 1x     4092x         4092x 4092x                   4092x 3627x 3627x 3627x 3627x 3609x 3627x       3627x 2986x 3626x               12x 12x 12x 12x 11x 11x 11x 11x     11x   12x       5554x 5554x     1841x               1841x 1641x     1841x 19x 19x 19x 19x     1841x 31x 31x 31x     1841x               5052x 5052x 5052x           6622x 116x 116x   6506x 6506x 6506x 6506x 6620x 6620x 6620x 6620x 6620x   6620x 6620x         6504x   6506x           6545x       5052x 5052x 5052x 1262x 1262x           5052x 5018x 5017x         1841x 34x 34x 34x 11x 11x       1841x 34x 34x 34x 11x 11x       1841x 574x 574x     1841x 1841x 1841x 1841x 1841x   1841x                                                                           4125x 4125x           4125x     4125x 4125x       5023x 5022x       718x       11x             984x 984x 984x 1462x       4125x  
// the reactive store ($reactive): proxy-based deep reactivity with
// dot-path dependency tracking, plus the effect-scope helper the renderer
// uses to tear down a subtree's bindings in one call
 
type ChangeListener = (value: any, dotKey: string) => void
type AnyChangeListener = (dotKey: string, value: any) => void
type ListenerOptions = { immediate?: boolean }
type Unsubscribe = () => void
 
export type EffectOptions = {
  // wake this effect for writes below its dependencies, not only on them
  deep?: boolean
  // internal: the extra stores this effect must also be registered with, so a
  // change in any of them wakes it (see ATTACH). Slot content is the only
  // thing that sets it, by way of createEffectScope - it is deliberately not
  // part of what this API tells users about
  alsoWakenBy?: Record<string, any>[]
}
 
export type ReactiveDeepData<T> = T & {
  $on: (dotKey: string, listener: ChangeListener, options?: ListenerOptions) => Unsubscribe
  $onAny: (listener: AnyChangeListener, options?: ListenerOptions) => Unsubscribe
  // runs `run` immediately, recording every dotKey it reads off this store, then
  // re-runs it whenever a changed dotKey overlaps one of those - see TrieNode.
  //
  // `deep` also wakes it for writes *below* what it read. It is for the one
  // shape the store cannot see into: an effect that hands a value to code
  // outside its view - a chart library, a canvas, a request - and so reads
  // nothing the proxy can record (see docs/reactive-data.md)
  $effect: (run: () => void, options?: EffectOptions) => Unsubscribe
  // drops this store's subscriptions to the stores nested inside it (see
  // bridge). A store that outlives the one holding it - the shared-state case -
  // would otherwise keep the dead holder's listeners on its own list forever
  $dispose: () => void
}
 
const getByPath = (obj: Record<string, any>, dotKey: string): any =>
  dotKey.split(".").reduce((acc, key) => (acc == null ? undefined : acc[key]), obj)
 
// only plain objects and arrays get deep-wrapped by the reactive store;
// class instances (Component79, Date, DOM nodes, ...) pass through untouched
// so their identity, prototypes and internals stay intact
const isPlainData = (value: object): boolean => {
  if (Array.isArray(value)) return true
  const proto = Object.getPrototypeOf(value)
  return proto === Object.prototype || proto === null
}
 
const walkLeaves = (obj: Record<string, any>, path: string, visit: (dotKey: string, value: any) => void) => {
  Object.entries(obj).forEach(([key, value]) => {
    const dotKey = path ? `${path}.${key}` : key
    if (value && typeof value === "object" && isPlainData(value)) walkLeaves(value, dotKey, visit)
    else visit(dotKey, value)
  })
}
 
// Effect deps live in a trie keyed by path segment: a write walks down its own
// segments, so the nodes it passes through are its ancestors and the subtree it
// lands on is its descendants, with no comparison against unrelated effects.
// `own` holds effects depending on this node's exact path; `deep` holds the ones
// that want everything under it too (see the `deep` flag on $effect).
//
// Which of the two directions actually wakes an effect is the thing that makes
// this fast, and it is not symmetric - see effectsFor and
// RECORD/2026-08-23.narrow-the-wake-rule.md
//
// `children`/`own`/`deep` are allocated on first use and `parent`/`segment` let
// a removal walk back up without re-splitting the path: a 10,000-row table is
// ~40,000 nodes, and three eager allocations each (a Map and two Sets, almost
// all of them staying empty) cost more than the index saves
// The effects registered on one node, for one of the two channels. Almost every
// dep has exactly ONE effect - `rows.7.label` is read by one text binding and
// nothing else - so the first one lives in a slot of its own and a Set is
// allocated only for the rest. It is the mirror of what indexEffect does one
// layer up with soleDep/soleNode, and it is worth it because deleting an effect
// from a one-entry Set was 60% of disposing it
// (RECORD/2026-08-27.one-effect-per-node.md).
//
// TWO fields rather than one union, and that is the whole design: a field that
// holds either an Effect or a Set makes every read of it polymorphic, and these
// are read on the notify walk - the hot path of every write in the library. One
// field of one shape each keeps both reads monomorphic.
//
// No invariant that `one` fills before `many`: an effect removed from `one`
// leaves it null with `many` still populated, and nothing has to shuffle
type TrieNode = {
  children: Map<string, TrieNode> | null
  own: Effect | null
  ownMany: Set<Effect> | null
  deep: Effect | null
  deepMany: Set<Effect> | null
  parent: TrieNode | null
  segment: string
}
 
export const addOwn = (node: TrieNode, effect: Effect) => {
  if (node.own === null) node.own = effect
  else if (node.own !== effect) (node.ownMany ??= new Set()).add(effect)
}
 
export const addDeep = (node: TrieNode, effect: Effect) => {
  if (node.deep === null) node.deep = effect
  else Eif (node.deep !== effect) (node.deepMany ??= new Set()).add(effect)
}
 
// a node whose last effect leaves has to end up empty on both fields, or
// isEmptyNode stops pruning it and the trie grows over the dead rows
export const removeOwn = (node: TrieNode, effect: Effect) => {
  if (node.own === effect) node.own = null
  else if (node.ownMany?.delete(effect) && node.ownMany.size === 0) node.ownMany = null
}
 
export const removeDeep = (node: TrieNode, effect: Effect) => {
  if (node.deep === effect) node.deep = null
  else if (node.deepMany?.delete(effect) && node.deepMany.size === 0) node.deepMany = null
}
 
export const eachOwn = (node: TrieNode, visit: (effect: Effect) => void) => {
  if (node.own !== null) visit(node.own)
  node.ownMany?.forEach(visit)
}
 
export const eachDeep = (node: TrieNode, visit: (effect: Effect) => void) => {
  if (node.deep !== null) visit(node.deep)
  node.deepMany?.forEach(visit)
}
 
// the dep an `ownKeys` read records, as a reserved last segment on the
// enumerated object's own path: an ordinary trie child that no walk for a real
// key can reach, and that the subtree sweep still reaches when the whole object
// is replaced - which is correct, a new object is a new key set. A real
// property named " keys" collides with it, the same way a flat key containing
// a dot collides with the nested path of the same name (tests/reactive.test.ts)
const KEYS_SEGMENT = " keys"
 
const keysPath = (path: string): string => (path ? `${path}.${KEYS_SEGMENT}` : KEYS_SEGMENT)
 
// an array's length, as a dep suffix: see indexable, which asks whether an
// effect holds the length of the container a slot sits in
const LENGTH_SUFFIX = ".length"
 
 
const createTrieNode = (parent: TrieNode | null, segment: string): TrieNode =>
  ({ children: null, own: null, ownMany: null, deep: null, deepMany: null, parent, segment })
 
const isEmptyNode = (node: TrieNode): boolean =>
  node.own === null && node.deep === null && !node.ownMany?.size && !node.deepMany?.size && !node.children?.size
 
// reads the raw object behind a store proxy. Module-level (not per-store) so a
// value that is already reactive - in this store or in another one - can be
// unwrapped before being wrapped again. Without it, handing the same object to
// two stores has each one wrapping the other's proxies, and since a wrap walks
// what it wraps, the nesting compounds until the process stops responding
const RAW = Symbol("jq79.raw")
 
// a free function rather than a method on the store, because the store API is
// served from the root proxy alone (see storeApi): `store.$toRaw()` would work
// and `store.user.$toRaw()` would not, which is the case callers actually have.
// The RAW symbol travels on every proxy at every depth, so this works anywhere.
// What it returns is the real object, not a copy: writes to it notify nobody
export const $toRaw = <T>(value: T): T => {
  let raw: any = value
  while (raw !== null && typeof raw === "object" && raw[RAW]) raw = raw[RAW]
  return raw
}
 
// marks a store's *root* proxy. A store put inside another store (a setup
// script's `const local = $reactive(...)`) has to pass through whole: it owns
// its listeners and its $on/$effect, so unwrapping it would strip away the very
// thing it is. Nested proxies carry no such marker and are unwrapped freely
const STORE = Symbol("jq79.store")
 
const isStore = (value: any): boolean =>
  value !== null && typeof value === "object" && value[STORE] === true
 
// active $effect() runs, innermost last - a module-level stack (rather than
// one per store) so nested effects across stores still nest correctly; reads
// during a proxy's `get` trap are attributed to whichever run is on top
const trackerStack: Set<string>[] = []
 
// runs fn with dependency tracking suspended - reads inside it are attributed
// to a throwaway set instead of the currently running effect
export const untracked = <T>(fn: () => T): T => {
  trackerStack.push(new Set())
  try {
    return fn()
  } finally {
    trackerStack.pop()
  }
}
 
// `reindex` holds one callback per store this effect is registered with (its
// own, plus any it was attached to), each keeping that store's trie in step
// with the deps of the last settled run. It lives on the effect rather than
// in a per-store map because `run` has to reach it without a lookup
export type Effect = { deps: Set<string>; run: () => void; reindex: Set<(deps: Set<string>) => void>; deep: boolean; order: number }
 
// creation order, module-wide. The flat `effects` set used to give this for
// free - iterating it ran effects oldest-first, so a parent's bindings always
// went before those of a child it had rendered. Matching through the trie
// returns them in walk order instead, and the tutorial's setup scripts are
// sensitive to it (a child effect running before its parent's re-sync reads
// state the parent has not written yet). Effects are ordered explicitly rather
// than left to whatever the index happens to yield
let effectsCreated = 0
 
// the deps an effect has before its first run, and what indexEffect compares
// its first run against. Never written to - every run installs a fresh set -
// so one frozen instance stands in for the two empty sets each effect used to
// allocate. 30,000 effects is 60,000 of them
const NO_DEPS: ReadonlySet<string> = new Set()
 
// an effect lives in exactly one store's `effects` set - the one whose
// $effect created it - and only that store's notify walks it. Content that
// reads two stores at once (a component's slot content: the parent's names
// plus the slot props the child passes it) needs one record in both sets, so
// a store serves this attach handle beside $on/$effect. Tracking already
// spans stores - trackerStack is module-level, so one run's deps are whatever
// it read, wherever it read it - only the waking didn't.
//
// Named like the compiled scripts' internals ($__effect, $__import) because it
// is one: `key in store` never answers true for a storeApi name, so `with`
// can't see it and no template expression can reach it.
//
// The cost, accepted: deps are dot-paths with no store namespace, so a name
// that exists in both stores wakes the effect from either. A spurious re-run,
// never a stale render
const ATTACH = "$__attach"
 
// the extra stores every effect created off a scope must be attached to. Read
// by createEffectScope off the scope it is given, so a scope can hand the
// arrangement down to whatever renders inside it (nested :each item scopes,
// a nested component's prop-sync effects) without every call site knowing
export const ALSO_WAKEN_BY = Symbol("jq79.alsoWakenBy")
 
export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepData<T> => {
  const exactListeners = new Map<string, Set<ChangeListener>>()
  const anyListeners = new Set<AnyChangeListener>()
  const effects = new Set<Effect>()
 
  // one proxy per raw object, for this store alone. Keyed by the *raw object*
  // rather than by its path, so identity travels with the object: :each diffs
  // its items by reference (Object.is), and a reordered list has to hand back
  // the same proxy for the same item or every row would re-render. The flip
  // side is that an object's path is fixed when it is first wrapped, so after a
  // reorder its notifications carry the old index - effects that read the list
  // itself still wake up (they hold its ancestor as a dep), which is what makes it a non-issue in
  // practice
  const proxies = new WeakMap<object, Record<string, any>>()
 
  // $on/$onAny/$effect are served from the root proxy's `get` instead of being
  // defined on the object: a store must leave nothing behind on the data it was
  // handed, and two stores over one object would otherwise clobber each other's
  // handles. Null-prototype, so `key in storeApi` can't match Object.prototype
  const storeApi: Record<string, any> = Object.create(null)
 
  // this store's dep index (see TrieNode). Segments come from splitting a
  // dotKey on ".", which is also why a flat key written as "a.b" indexes
  // exactly where the nested a.b lives - the collision dot-paths have always
  // had, preserved rather than special-cased
  const depTrie = createTrieNode(null, "")
 
  // hands back the node it registered on, which is what lets a removal skip the
  // path entirely (see indexEffect)
  const insertDep = (dep: string, effect: Effect): TrieNode => {
    let node = depTrie
    dep.split(".").forEach(segment => {
      const children = (node.children ??= new Map())
      let child = children.get(segment)
      if (!child) {
        child = createTrieNode(node, segment)
        children.set(segment, child)
      }
      node = child
    })
    if (effect.deep) addDeep(node, effect)
    else addOwn(node, effect)
    return node
  }
 
  // prunes the nodes it empties on the way back up, so a list that churns
  // through rows doesn't leave the trie growing over the dead ones. Follows
  // `parent` rather than re-walking from the root: tearing down a 10,000-row
  // table is 30,000 of these, and splitting each path again to find a node the
  // caller was already holding is most of what that used to cost
  const removeDep = (node: TrieNode, effect: Effect) => {
    if (effect.deep) removeDeep(node, effect)
    else removeOwn(node, effect)
    let current: TrieNode | null = node
    while (current?.parent && isEmptyNode(current)) {
      current.parent.children!.delete(current.segment)
      current = current.parent
    }
  }
 
  const nodeAt = (dep: string): TrieNode | undefined => {
    let node: TrieNode | undefined = depTrie
    for (const segment of dep.split(".")) {
      node = node.children?.get(segment)
      if (!node) return undefined
    }
    return node
  }
 
  // every effect this write concerns. Two directions, and they are not
  // symmetric:
  //
  // - **downwards**, always: whatever hangs off the node the walk lands on. A
  //   dep of "user.name" hears `user = {...}`, because replacing the object
  //   replaced the name with it.
  // - **upwards**, only where an ancestor dep is the only channel a change
  //   has (see `coarsePath`). A dep of "data" does NOT hear
  //   `data[5].label = x`: an effect that read the array on its way to row
  //   7's label has no stake in row 5's, and waking all of them is what made
  //   100 row writes cost 100,000 effect runs - see
  //   RECORD/2026-08-23.narrow-the-wake-rule.md
  //
  // Returned as a snapshot, so the effects it wakes can reindex themselves -
  // or dispose each other - while it drains
  const effectsFor = (dotKey: string): Set<Effect> => {
    const matched = new Set<Effect>()
    const sweep = (from: TrieNode) => {
      const pending = from.children ? [...from.children.values()] : []
      while (pending.length) {
        const next = pending.pop()!
        eachOwn(next, effect => matched.add(effect))
        eachDeep(next, effect => matched.add(effect))
        next.children?.forEach(child => pending.push(child))
      }
    }
 
    let node: TrieNode | undefined = depTrie
    const segments = dotKey.split(".")
    let path = ""
    for (let depth = 0; depth < segments.length - 1; depth++) {
      node = node.children?.get(segments[depth])
      if (!node) return matched
      path = path ? `${path}.${segments[depth]}` : segments[depth]
      eachDeep(node, effect => matched.add(effect))
      // a nested store sits here: an effect that read through it holds this
      // path and nothing below it, so its own set is the whole channel
      if (bridges.has(path)) eachOwn(node, effect => matched.add(effect))
      // ...whereas an array's length stands for the array: everything that
      // read an element has to hear a truncation, and those deps are below
      if (depth === segments.length - 2 && segments[depth + 1] === "length") {
        eachOwn(node, effect => matched.add(effect))
        sweep(node)
      }
    }
    node = node.children?.get(segments[segments.length - 1])
    if (!node) return matched
    eachOwn(node, effect => matched.add(effect))
    eachDeep(node, effect => matched.add(effect))
    sweep(node)
    return matched
  }
 
  // whether path[start..end) is an array index - all digits, so `data.6` is a
  // slot and `data.tags` is not. Read off the path in place: this runs per
  // ancestor of every dep an effect holds
  const isSlotSegment = (path: string, start: number, end: number): boolean => {
    Iif (start === end) return false
    for (let at = start; at < end; at++) {
      const code = path.charCodeAt(at)
      if (code < 48 || code > 57) return false
    }
    return true
  }
 
  // Reaching `data[5].label` reads three paths and tracks all three, but for an
  // ordinary effect an ancestor carries no information the leaf doesn't: a
  // write to "data" reaches "data.5.label" through the subtree sweep anyway.
  // Dropping them is a third of the index to build, hold and tear down on a
  // list of any size.
  //
  // An **array slot** is the exception, and it is the reason this isn't simply
  // "every ancestor". A splice wakes the slots it shifted and deliberately
  // does not sweep below them - what sits under a slot belongs to the row that
  // was wrapped there, and that row did not change (see splicedAt). So the
  // effect that read `data[6].label` has to keep `data.6` as a dep of its own,
  // or removing a row ahead of it leaves the binding showing the old row's
  // label. A row binding inside a `:each` never read the slot and so keeps
  // nothing extra - it holds one dep and doesn't reach this code at all
  //
  // ...unless the effect also holds the container's **length**, and then the
  // slots are redundant again: a splice always changes the length and
  // `notifyReplaced` announces it exactly, so whoever tracked the length hears
  // every shift there is without a slot dep of their own. That is not a
  // detail - the `:each` list effect reads the length *and* every slot, so
  // without this clause it indexes a second dep per row, and `clearLarge`
  // measured +4.5% (4 of 4 rounds, ±2.7% noise) tearing them all down again
  //
  // Not for a `deep` effect, where it is exactly backwards - a forwarding
  // effect wakes off its *ancestor* entries, so its shallowest dep is the one
  // doing the work and the leaves are the redundant ones
  const indexable = (effect: Effect, deps: Set<string>): Set<string> => {
    if (effect.deep || deps.size < 2) return deps
    // an ancestor that isn't a slot is redundant, and they are marked by
    // walking each dep's own dots rather than comparing deps against each
    // other: a `:each` over 10,000 rows tracks 10,000 deps, and the pairwise
    // version of this was 100,000,000 string comparisons
    // which containers this effect tracks the length of, resolved in one pass
    // so the walk below can ask without building a `${container}.length` per
    // slot it meets - a list effect meets one per row
    const lengthTracked = new Set<string>()
    deps.forEach(dep => { if (dep.endsWith(LENGTH_SUFFIX)) lengthTracked.add(dep.slice(0, -LENGTH_SUFFIX.length)) })
 
    const redundant = new Set<string>()
    deps.forEach(dep => {
      let from = 0
      // the ancestor one level up, carried rather than re-sliced: it is the
      // container of the segment being looked at, and it was already built
      let parent = ""
      for (let dot = dep.indexOf("."); dot !== -1; dot = dep.indexOf(".", dot + 1)) {
        const ancestor = dep.slice(0, dot)
        // `from > 0` because a slot needs a container to be a slot of: a
        // top-level numeric key is a key of the store's root object, and no
        // splice can shift it
        const slot = from > 0 && isSlotSegment(dep, from, dot)
        if (!slot || lengthTracked.has(parent)) redundant.add(ancestor)
        parent = ancestor
        from = dot + 1
      }
    })
    if (!redundant.size) return deps
    const kept = new Set<string>()
    deps.forEach(dep => { if (!redundant.has(dep)) kept.add(dep) })
    return kept
  }
 
  // registers `effect` with this store's trie and keeps it in step. Each store
  // tracks what it indexed for the effect separately, because a detach must
  // clear this trie without touching the others
  const indexEffect = (effect: Effect): Unsubscribe => {
    // dep -> the node it sits on, so removal never re-walks a path: the
    // overwhelmingly common re-run has identical deps and touches the trie not
    // at all, and a disposal goes straight to the nodes it registered
    // almost every effect ends up with exactly one dep once the redundant
    // ancestors are pruned - a row binding reads one path - so the single case
    // is held in two slots and the Map is allocated only when a second arrives
    let soleDep: string | null = null
    let soleNode: TrieNode | null = null
    let indexed: Map<string, TrieNode> | null = null
    // what the last run tracked, before pruning. The comparison has to happen
    // against these rather than against what is indexed, because pruning them
    // is itself work this fast path exists to skip
    let lastTracked: ReadonlySet<string> = NO_DEPS
 
    const placed = (dep: string): boolean =>
      indexed ? indexed.has(dep) : soleDep === dep
 
    const place = (dep: string) => {
      const node = insertDep(dep, effect)
      if (indexed) indexed.set(dep, node)
      else if (soleDep === null) { soleDep = dep; soleNode = node }
      else {
        indexed = new Map([[soleDep, soleNode!], [dep, node]])
        soleDep = soleNode = null
      }
    }
 
    const unplaceStale = (deps: Set<string>) => {
      if (indexed) {
        indexed.forEach((node, dep) => {
          if (deps.has(dep)) return
          removeDep(node, effect)
          indexed!.delete(dep)
        })
        return
      }
      if (soleDep !== null && !deps.has(soleDep)) {
        removeDep(soleNode!, effect)
        soleDep = soleNode = null
      }
    }
 
    const unplaceAll = () => {
      if (indexed) indexed.forEach(node => removeDep(node, effect))
      else if (soleNode) removeDep(soleNode, effect)
      indexed = null
      soleDep = soleNode = null
    }
    const sync = (tracked: Set<string>) => {
      // an effect that re-runs almost always reads exactly what it read last
      // time, and this is on the path of every single run: same count and every
      // path seen before means nothing about the index can have changed
      if (tracked.size === lastTracked.size) {
        let unchanged = true
        tracked.forEach(dep => { unchanged &&= lastTracked.has(dep) })
        if (unchanged) return
      }
      lastTracked = tracked
      const deps = indexable(effect, tracked)
      unplaceStale(deps)
      deps.forEach(dep => { if (!placed(dep)) place(dep) })
    }
    effect.reindex.add(sync)
    sync(effect.deps) // an effect attached after it first ran arrives with deps
    return () => {
      effect.reindex.delete(sync)
      unplaceAll()
      lastTracked = NO_DEPS
    }
  }
 
  // wakes the effects sitting on one exact path, without the subtree sweep a
  // full notify does. Two callers want this: a key-set change (nothing under
  // the object changed, only which keys it has) and a container replaced by one
  // holding the same elements (see notifyReplaced)
  const wakeExactly = (dep: string) => {
    const node = nodeAt(dep)
    if (node) {
      const matched = new Set<Effect>()
      eachOwn(node, effect => matched.add(effect))
      eachDeep(node, effect => matched.add(effect))
      runMatched(matched)
    }
  }
 
  // an object's key set changed. Effects only - a key set isn't a value, so
  // there is nothing to hand $on/$onAny that they don't already get from the
  // key's own notification
  const notifyKeys = (path: string) => wakeExactly(keysPath(path))
 
  // oldest first, and re-checking membership as it goes: an effect disposed by
  // an earlier one in this same pass (a list diff tearing down the rows it just
  // woke) must not run
  const runMatched = (matched: Set<Effect>) => {
    const ordered = Array.from(matched)
    // effects are usually collected in creation order already - one node's set
    // is filled as its effects are made, and a subtree sweep of a freshly-built
    // list walks them the same way. Checking costs one pass; sorting a woken
    // set of 10,000 costs rather more
    let sorted = true
    for (let i = 1; sorted && i < ordered.length; i++) sorted = ordered[i - 1].order < ordered[i].order
    if (!sorted) ordered.sort((a, b) => a.order - b.order)
    ordered.forEach(effect => { if (effects.has(effect)) effect.run() })
  }
 
  const notify = (dotKey: string, value: any, isNewKey = false) => {
    exactListeners.get(dotKey)?.forEach(listener => listener(value, dotKey))
    anyListeners.forEach(listener => listener(dotKey, value))
    // a newly-created key re-runs every effect: an effect that read the
    // name while it didn't exist couldn't track it (`with` skipped the
    // store entirely), so dep matching would never wake it up
    if (isNewKey) effects.forEach(effect => effect.run())
    // `effects.has` stands in for the membership check `effects.forEach` used
    // to give for free: an effect disposed by an earlier effect in this same
    // notify (a list diff tearing down the rows it just woke) must not run
    else runMatched(effectsFor(dotKey))
  }
 
  // both sides are containers of the same kind, so what changed can be asked
  // rather than assumed. Not the same object - that case never gets here (a
  // same-reference write is the deep-touch channel and stays loud), and not a
  // store on either side, which passes through whole
  const replaceable = (previous: any, next: any): boolean =>
    previous !== next &&
    previous !== null && next !== null &&
    typeof previous === "object" && typeof next === "object" &&
    !isStore(previous) && !isStore(next) &&
    isPlainData(previous) && isPlainData(next) &&
    Array.isArray(previous) === Array.isArray(next)
 
  // which keys hold a different value than they did, or null when so many do
  // that notifying them one at a time would cost more than sweeping the
  // container. Arrays - the case this exists for - are walked by index, so a
  // 10,000 element list is compared without building a key array or a set of
  // them: that bookkeeping alone was costing more than it saved on every
  // replacement that ends up sweeping anyway
  const GIVE_UP: null = null
 
  // `exact` marks the keys as slots whose occupant *moved* rather than values
  // that changed: nothing under them is different, so the subtree each one
  // would otherwise sweep must be left alone (see splicedAt)
  // `keysChanged` answers the `@keys` dep - what an effect records when it reads
  // Object.keys(container). It is not `keys.length > 0`: a replacement can
  // change every value and no key name at all, and it used to be decided by
  // comparing key COUNTS, which misses swapping one name for another
  // (RECORD/2026-08-25.two-defects-a-review-found.md)
  type Difference = { keys: string[]; exact: boolean; keysChanged: boolean }
 
  const NOT_SPLICED = -1
 
  // one element inserted into or removed from an array: from the cut onwards
  // every element is the very same element, one slot over. `data.filter(...)`
  // - the most ordinary edit anyone makes to a list - reads as "over half the
  // container differs" to the walk below, which gives up and has the whole
  // subtree swept; this recognises the shift for what it is. O(n), no
  // allocation, and O(1) to reject on any pair whose lengths differ by
  // anything but one. See RECORD/2026-08-23.notify-a-splice.md
  const splicedAt = (previous: any[], next: any[]): number => {
    const grew = next.length > previous.length
    const shorter = grew ? previous : next
    const longer = grew ? next : previous
    let start = 0
    while (start < shorter.length && Object.is($toRaw(shorter[start]), $toRaw(longer[start]))) start++
    for (let index = start; index < shorter.length; index++) {
      if (!Object.is($toRaw(shorter[index]), $toRaw(longer[index + 1]))) return NOT_SPLICED
    }
    return start
  }
 
  const whatChanged = (previous: any, next: any): Difference | null => {
    if (Array.isArray(next)) {
      const before = previous.length
      const after = next.length
      // an array's keys are 0..length-1, so its length IS its key set - and
      // nothing here walks the 10,000 elements a second time to learn that
      const keysChanged = before !== after
      // nothing on one side means nothing to reuse on the other
      if (!before || !after) return GIVE_UP
      const span = Math.max(before, after)
      if (Math.abs(before - after) === 1) {
        const start = splicedAt(previous, next)
        if (start !== NOT_SPLICED) {
          const slots: string[] = []
          for (let index = start; index < span; index++) slots.push(String(index))
          return { keys: slots, exact: true, keysChanged }
        }
      }
      const changed: string[] = []
      for (let index = 0; index < span; index++) {
        if (Object.is($toRaw(previous[index]), $toRaw(next[index]))) continue
        changed.push(String(index))
        if (changed.length * 2 >= span) return GIVE_UP
      }
      return { keys: changed, exact: false, keysChanged }
    }
    const previousKeys = Object.keys(previous)
    const nextKeys = Object.keys(next)
    const keys = new Set([...previousKeys, ...nextKeys])
    Iif (!keys.size) return GIVE_UP
    const changed: string[] = []
    keys.forEach(key => { if (!Object.is($toRaw(previous[key]), $toRaw(next[key]))) changed.push(key) })
    // Object.keys answers in insertion order, so the ORDER is part of the answer
    // and a rebuild that sorts the same names - Object.fromEntries(entries.sort())
    // - changes what an effect reading it sees. A union larger than either side
    // would catch a swapped name; walking the two arrays catches that and the
    // reorder both, off arrays this already had to build
    const keysChanged =
      previousKeys.length !== nextKeys.length || previousKeys.some((key, index) => key !== nextKeys[index])
    return changed.length * 2 >= keys.size ? GIVE_UP : { keys: changed, exact: false, keysChanged }
  }
 
  // A container replaced by another container: notify the elements that
  // actually differ instead of the container and everything under it.
  // `data = [...data, ...more]` holds the very same row objects at every index
  // it had before, so waking all thirty thousand of their bindings to re-render
  // identical output is ~150ms of a 208ms append - see
  // RECORD/2026-08-23.notify-the-difference.md
  //
  // One level deep on purpose: an element that differs is a changed value, and
  // notifying it sweeps its own subtree, which is what a changed value deserves.
  // A spliced element is the exception - it did not change, it moved - and its
  // slots are woken without that sweep (see splicedAt)
  const notifyReplaced = (dotKey: string, previous: any, next: any, notified: any) => {
    // one write, one wake: every path below contributes to a single set that
    // runs once at the end. Notifying them one at a time re-ran an effect that
    // depends on several of them once per path
    const matched = new Set<Effect>()
    const collectExact = (dep: string) => {
      const node = nodeAt(dep)
      if (node) {
        eachOwn(node, effect => matched.add(effect))
        eachDeep(node, effect => matched.add(effect))
      }
    }
 
    const difference = whatChanged(previous, next)
    // when most of the container differs there is nothing to spare: `data = []`
    // and a wholesale replacement change every key, and reaching each one
    // through its own trie walk costs more than the single sweep it replaces.
    // whatChanged says so by giving up. The decision has to come before
    // anything is announced, or the plain notify would fire the container's
    // listeners a second time
    if (!difference) return notify(dotKey, notified)
 
    exactListeners.get(dotKey)?.forEach(listener => listener(notified, dotKey))
    // $onAny hears the container and nothing else, exactly as it did when this
    // was one notify. It is what a bridge re-notifies upstairs, and the holder
    // sweeps its own side off that one path - announcing each changed element
    // as well would be a thousand redundant notifications for the same news
    anyListeners.forEach(listener => listener(dotKey, notified))
    // the container itself did change: whoever read it, or forwards it whole,
    // hears that - but nothing is swept on its account
    collectExact(dotKey)
 
    difference.keys.forEach(key => {
      const child = `${dotKey}.${key}`
      // the value is built only for a listener that asked for it: a splice
      // announces every slot it shifted, and wrapping a thousand rows to hand
      // them to nobody is exactly the kind of work this path exists to avoid
      const listeners = exactListeners.get(child)
      if (listeners) {
        const after = $toRaw(next[key])
        const value = isWrappable(after) ? wrap(after, child) : after
        listeners.forEach(listener => listener(value, child))
      }
      // a shifted slot has a new occupant and nothing under it changed - the
      // rows themselves are untouched - so whoever read the slot is the whole
      // audience, and the bindings below it are not woken
      if (difference.exact) collectExact(child)
      else effectsFor(child).forEach(effect => matched.add(effect))
    })
    // the key SET, not the count: replacing { a, b, c, d } with { a, b, c, e }
    // leaves both at four and changes what Object.keys answers
    if (difference.keysChanged) collectExact(keysPath(dotKey))
    // an array's length is a real dep (a `:each` reads it on its way through
    // list.map) and is not one of the keys walked above. Collected exactly:
    // the sweep a length write normally carries is for a truncation, and the
    // elements that a shrink dropped are already in `keys`
    if (Array.isArray(next) && previous.length !== next.length) {
      const lengthKey = `${dotKey}.length`
      exactListeners.get(lengthKey)?.forEach(listener => listener(next.length, lengthKey))
      collectExact(lengthKey)
    }
    runMatched(matched)
  }
 
  const isWrappable = (value: any): value is Record<string, any> =>
    value !== null && typeof value === "object" && isPlainData(value)
 
  // a store nested inside this one keeps its own listeners and its own effects,
  // and this store's effects are not among them - so a write through the inner
  // store notifies nobody out here, and a component rendering `{{ cart.items }}`
  // off a `$reactive` it was handed would never update. The holder subscribes
  // instead, and re-notifies the inner store's changes under the path it sits at
  // ("items.0" -> "cart.items.0"). An effect that read through `cart` recorded
  // exactly that path's ancestor as a dependency, so the trie walk wakes it.
  // Chains compose: re-notifying runs this store's own $onAny listeners, which
  // is how a store two levels down still reaches the top
  const bridges = new Map<string, { store: any; unsubscribe: Unsubscribe }>()
 
  const bridge = (store: any, path: string) => {
    const current = bridges.get(path)
    if (current?.store === store) return
    current?.unsubscribe()
    bridges.set(path, {
      store,
      unsubscribe: store.$onAny((dotKey: string, value: any) => notify(`${path}.${dotKey}`, value)),
    })
  }
 
  // the key no longer holds the store it held: stop listening to it
  const unbridge = (path: string) => {
    bridges.get(path)?.unsubscribe()
    bridges.delete(path)
  }
 
  // the reactive view of `raw`, created on demand. Callers must hand it a raw
  // object (see $toRaw at both call sites): wrapping a proxy is what compounds
  const wrap = (raw: Record<string, any>, path: string): Record<string, any> => {
    const cached = proxies.get(raw)
    if (cached) return cached
 
    // keys that were deleted off this object. `with ($scope)` resolves a name
    // through [[HasProperty]], so without a claim here a deleted key would fall
    // through to globalThis and the *whole* expression would die of
    // ReferenceError - `user ? user.name : "none"` must take its else branch
    // instead. The cost: `"user" in store` stays true after a delete
    let tombstones: Set<string> | null = null
 
    const proxy: Record<string, any> = new Proxy(raw, {
      has(target, key) {
        return Reflect.has(target, key) || (typeof key === "string" && tombstones?.has(key) === true)
      },
      // reading the key set is a dependency of its own: `Object.keys(props)`,
      // `{...props}`, `for...in` and renderEach's walk of a list's keys all care
      // about which keys exist, not about what any one of them holds. It used
      // to be caught only by the coarse ancestor rule, which is now gone -
      // this is the same job Svelte gives a per-object `version` signal, held
      // as an ordinary trie child under a reserved final segment (see
      // KEYS_SEGMENT), so adds and deletes wake exactly the effects enumerating
      ownKeys(target) {
        trackerStack[trackerStack.length - 1]?.add(keysPath(path))
        return Reflect.ownKeys(target)
      },
      get(target, key, receiver) {
        if (key === RAW) return target
        if (key === STORE) return path === ""
        if (typeof key !== "string") return Reflect.get(target, key, receiver)
        if (path === "" && key in storeApi) return storeApi[key]
 
        const dotKey = path ? `${path}.${key}` : key
        trackerStack[trackerStack.length - 1]?.add(dotKey)
 
        // nested objects are wrapped here rather than up front, so the object
        // handed to $reactive is never rewritten
        const value = Reflect.get(target, key, receiver)
        if (isStore(value)) {
          bridge(value, dotKey)
          return value
        }
 
        const raw = $toRaw(value)
        return isWrappable(raw) ? wrap(raw, dotKey) : raw
      },
      set(target, key: string, value, receiver) {
        // an assignment delegated up the prototype chain from a derived scope
        // (Object.create(store) child, or a wrapping proxy): if the key isn't
        // a real property of this store, honor the receiver so the new binding
        // lands on the derived scope - a scope-local variable, not a store
        // mutation, so no notify. If the key IS a store property, fall through
        // and mutate the store itself regardless of receiver, so assignments
        // like @click="count = count + 1" work from any nested scope
        if (receiver !== proxy && !Object.prototype.hasOwnProperty.call(target, key)) {
          return Reflect.set(target, key, value, receiver)
        }
 
        const dotKey = path ? `${path}.${key}` : key
        // store the raw value, never a proxy - including one of our own, so
        // that `list = [list[1], list[0]]` doesn't write proxies back into the
        // data. Reads re-wrap it, from the cache, as the very same proxy. A
        // whole store assigned in is the exception: it stays as it is
        const stored = isStore(value) ? value : $toRaw(value)
        const isNewKey = !Object.prototype.hasOwnProperty.call(target, key)
        // a primitive write that changes nothing notifies nobody: it's what
        // lets an effect write the value it just read (a normalizing
        // assignment, a prop sync) and settle instead of waking itself
        // forever. Only primitives and functions: re-writing the SAME object
        // reference stays loud, because that is the cross-store "deep touch"
        // channel - a parent's prop sync forwards `user.name = x` to the
        // child's store by re-assigning the same `user`, and the child's
        // listeners live on the child's store, not the parent's. A new key
        // always announces itself - the sweep is its whole point
        if (!isNewKey && Object.is(target[key], stored) && (stored === null || typeof stored !== "object")) return true
        const previous = target[key]
        target[key] = stored
        tombstones?.delete(key) // the key exists again: no claim needed
        if (isStore(stored)) bridge(stored, dotKey)
        else unbridge(dotKey)
        const notified = isStore(stored) || !isWrappable(stored) ? stored : wrap(stored, dotKey)
        // a new key already re-runs every effect in the store (see notify), so
        // the key set growing needs no announcement of its own - only a delete,
        // which wakes precisely, does
        if (!isNewKey && replaceable(previous, stored)) notifyReplaced(dotKey, previous, stored, notified)
        else notify(dotKey, notified, isNewKey)
        return true
      },
      // `delete data.user` is a plain-object mutation like any other, so it
      // notifies like one - with `undefined`, which is what a read returns
      // afterwards. Array methods that shrink (pop, splice) delete their dead
      // slots through this trap too. No new-key sweep: whoever depended on the
      // key tracked it while it existed, so dep matching wakes exactly them
      deleteProperty(target, key) {
        Iif (typeof key !== "string") return Reflect.deleteProperty(target, key)
        const had = Object.prototype.hasOwnProperty.call(target, key)
        const deleted = Reflect.deleteProperty(target, key)
        if (deleted && had) {
          const dotKey = path ? `${path}.${key}` : key
          ;(tombstones ??= new Set()).add(key)
          unbridge(dotKey) // a nested store it held: stop listening to it
          notify(dotKey, undefined)
          // the key set shrank: whoever enumerated this object hears it even
          // if it never read the key that went (see the ownKeys trap)
          notifyKeys(path)
        }
        return deleted
      }
    })
 
    proxies.set(raw, proxy)
    return proxy
  }
 
  const reactive = wrap($toRaw(data), "") as ReactiveDeepData<T>
 
  // a store handed in with the data (a prop, or render data) is bridged here
  // rather than on first read, so a listener registered before anything reads
  // the key still hears it. Only the top level is scanned: that's where a prop
  // lands, and descending would mean walking whatever else was handed in - a
  // highlighter, an API client - to its leaves. A store sitting deeper is
  // bridged when the read that reaches it wraps its parent
  Object.entries($toRaw(data)).forEach(([key, value]) => {
    if (isStore(value)) bridge(value, key)
  })
 
  const $on = (dotKey: string, listener: ChangeListener, { immediate = false }: ListenerOptions = {}): Unsubscribe => {
    if (!exactListeners.has(dotKey)) exactListeners.set(dotKey, new Set())
    exactListeners.get(dotKey)!.add(listener)
    if (immediate) listener(getByPath(reactive, dotKey), dotKey)
    return () => exactListeners.get(dotKey)?.delete(listener)
  }
 
  const $onAny = (listener: AnyChangeListener, { immediate = false }: ListenerOptions = {}): Unsubscribe => {
    anyListeners.add(listener)
    if (immediate) walkLeaves(reactive, "", (dotKey, value) => listener(dotKey, value))
    return () => anyListeners.delete(listener)
  }
 
  const $effect = (run: () => void, { deep = false, alsoWakenBy }: EffectOptions = {}): Unsubscribe => {
    // a notify landing while this effect runs (an item's render writing to
    // the store, waking the very effect that is rendering it) must not
    // re-enter mid-run - the half-done run would race its own repeat over
    // shared state, which is how :each once tripled its rows. It marks the
    // run dirty instead, and repeats *after* it finishes, against settled
    // state, until clean. Still fully synchronous: everything happens before
    // the triggering assignment returns
    let running = false
    let dirty = false
    const effect: Effect = {
      deps: NO_DEPS as Set<string>,
      reindex: new Set(),
      deep,
      order: effectsCreated++,
      run: () => {
        if (running) {
          dirty = true
          return
        }
        running = true
        try {
          let cycles = 0
          do {
            dirty = false
            const deps = new Set<string>()
            trackerStack.push(deps)
            try {
              run()
            } finally {
              trackerStack.pop()
              effect.deps = deps
            }
          } while (dirty && ++cycles < 100)
          // an effect that keeps writing its own dependencies used to die by
          // stack overflow; now it is cut off and named
          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")
        } finally {
          running = false
          // the settled deps are the only ones worth indexing: the repeats of
          // a dirty run overwrite each other, and a notify that lands mid-run
          // is queued rather than dispatched, so nothing reads the index in
          // between. In `finally` so a run that throws still leaves the index
          // matching the deps the run did commit
          effect.reindex.forEach(sync => sync(effect.deps))
        }
      },
    }
    effects.add(effect)
    const stopIndexing = indexEffect(effect)
    const forget = () => {
      effects.delete(effect)
      stopIndexing()
    }
    // the shared case is rare (only slot content asks for it) and this
    // function is on the stack for as long as whatever it renders - a
    // component that renders itself stacks 200 of these - so it keeps the
    // shape it had, and the extra bookkeeping lives in its own frame
    if (alsoWakenBy?.length) return attachAndRun(effect, alsoWakenBy, forget)
    effect.run()
    return forget
  }
 
  // attached before the first run, so a store that notifies during it (a setup
  // script's write, a prop sync) reaches this effect like any other
  const attachAndRun = (effect: Effect, alsoWakenBy: Record<string, any>[], forget: Unsubscribe): Unsubscribe => {
    const detach = alsoWakenBy.map(store => store?.[ATTACH]?.(effect)).filter(Boolean) as Unsubscribe[]
    effect.run()
    return () => {
      forget()
      detach.forEach(drop => drop())
    }
  }
 
  const $__attach = (effect: Effect): Unsubscribe => {
    effects.add(effect)
    const stopIndexing = indexEffect(effect)
    return () => {
      effects.delete(effect)
      stopIndexing()
    }
  }
 
  const $dispose = () => {
    bridges.forEach(({ unsubscribe }) => unsubscribe())
    bridges.clear()
  }
 
  storeApi.$on = $on
  storeApi.$onAny = $onAny
  storeApi.$effect = $effect
  storeApi.$dispose = $dispose
  storeApi[ATTACH] = $__attach
 
  return reactive
}
// groups the disposers of every $effect created for one rendered subtree
// (an :if branch, an :each item, ...) so the whole subtree's bindings can be
// torn down in one call when that subtree is replaced/removed. `scope.$effect`
// resolves through the prototype chain up to the root store no matter how
// many nested :each scopes sit in between (see renderEach's itemScope)
export type EffectScope = {
  effect: (run: () => void) => void
  // registers an arbitrary cleanup (e.g. destroying a nested component) to
  // run when this subtree is torn down
  onDispose: (fn: Unsubscribe) => void
  // re-runs every effect registered on this scope, nested scopes excluded:
  // how :each tells a reused, repositioned entry's dep-less bindings (the
  // `{{ $index }}`-only case) about their move. Deps stay as they were -
  // callers run it untracked
  refresh: () => void
  dispose: () => void
}
 
// `deep` marks every effect this scope creates as forwarding a value wholesale
// rather than reading into it - the prop-sync scope, and nothing else so far.
// See the `deep` flag on $effect
//
// A class rather than an object of closures, and its arrays built on demand,
// because a :each makes one of these *per row*: the closure form allocated the
// two arrays and four closures for every one of them, and a 10,000-row table
// is 70,000 objects that exist to hold, on the common path, three disposers.
// The prototype's methods are shared, and a row that registers nothing (a
// static template) now allocates one object and no arrays at all.
// See RECORD/2026-08-23.where-the-create-time-goes.md
//
// Prototype methods need their receiver: a caller that hands one on as a bare
// function (`runSetupScript(..., fx.effect, ...)` did) must wrap it instead
// (`run => fx.effect(run)`). Both such call sites are in renderComponent
class Scope implements EffectScope {
  // built on first use: `runs` in particular is only ever read by refresh(),
  // which only a :each whose template names a position ever calls
  private disposers: Unsubscribe[] | null = null
  private runs: (() => void)[] | null = null
  // one options object for the whole scope instead of one per effect - $effect
  // destructures it on entry and keeps nothing. Left undefined on the common
  // path, which is $effect's own fast path
  private options: EffectOptions | undefined
 
  constructor(private scope: Record<string, any>, deep: boolean) {
    // whatever the scope was handed (slot content is the only thing that sets
    // it today): the stores this scope's effects belong to besides their own
    const alsoWakenBy: Record<string, any>[] | undefined = (scope as any)[ALSO_WAKEN_BY]
    this.options = deep || alsoWakenBy ? { deep, alsoWakenBy } : undefined
  }
 
  effect(run: () => void) {
    ;(this.disposers ??= []).push(this.scope.$effect(run, this.options))
    ;(this.runs ??= []).push(run)
  }
 
  onDispose(fn: Unsubscribe) {
    ;(this.disposers ??= []).push(fn)
  }
 
  refresh() {
    this.runs?.forEach(run => run())
  }
 
  dispose() {
    // detached before draining, not copied out of the way with splice(0): a
    // disposer that registers another one is as lost either way, and the copy
    // was an array per row on the teardown path
    const disposers = this.disposers
    this.disposers = null
    this.runs = null
    if (disposers) for (let i = 0; i < disposers.length; i++) disposers[i]()
  }
}
 
export const createEffectScope = (scope: Record<string, any>, deep = false): EffectScope => new Scope(scope, deep)