r-state-tree 0.5.0 → 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/README.md CHANGED
@@ -60,7 +60,7 @@ export default defineConfig({
60
60
  - Stores: application/view state containers. Create with `createStore()`, attach with `mount()`. Compose with `@child` (single or arrays, stable via `{ key }`). React to changes with `effect`/`reaction` and store lifecycles (`storeDidMount`/`storeWillUnmount`). Update reactive `props` via `updateStore()`.
61
61
  - Models: domain state containers. Create with `Model.create()`. Persistent via snapshots (`toSnapshot`, `applySnapshot`, `onSnapshot`, diffs via `onSnapshotDiff`). Structure with `@state`, `@child`, identifiers via `@id`, and references via `@modelRef`.
62
62
  - Context: pass data through Store/Model trees without prop drilling using `createContext<T>()`, `[Context.provide]`, and `Context.consume(this)`. Context is reactive and can be overridden by descendants.
63
- - Reactivity: powered by signals. Use `@observable`, `@computed`, `effect`, `reaction`, `batch`, and `untracked` for precise updates.
63
+ - Reactivity: powered by signals. Use `observable()`, `@computed`, `effect`, `reaction`, `batch`, and `untracked` for precise updates.
64
64
 
65
65
  ## Separation of concerns
66
66
 
@@ -352,118 +352,193 @@ class ListStore extends Store {
352
352
 
353
353
  ## Mutability rules
354
354
 
355
- - Models: prefer in-place mutation for arrays/maps/sets (`push`, `splice`, `set`, etc.). Replace the whole structure only when you intentionally want to swap the instance.
356
- - Stores: store fields are reactive; in-place mutation is fine. Reassign the entire structure only if you need identity replacement semantics.
355
+ - Models: model fields are shallow-reactive, but values are not auto-wrapped. For raw `@state` arrays/objects, prefer **reassignment** (immutability) so snapshots stay up to date. If you want in-place mutation (`push`, `splice`, `set`, etc.) to trigger updates and snapshot invalidation, store an `observable()` container (or `signal()`) in `@state`.
356
+ - Stores: store fields are shallow-reactive. Use `observable()` containers (or `signal()`) when you want in-place mutations of nested values/collections to trigger updates.
357
357
 
358
358
  ## Observable classes
359
359
 
360
- For reactive class instances outside the Model/Store system, use `@observable` and `@computed` decorators:
360
+ For reactive class instances outside the Model/Store system, wrap state with `observable()`:
361
361
 
362
362
  ```ts
363
- import { Observable, observable, computed, effect } from "r-state-tree";
363
+ import { observable, computed, effect } from "r-state-tree";
364
364
 
365
- class Counter extends Observable {
366
- @observable count = 0;
367
- @observable step = 1;
365
+ class Counter {
366
+ state = observable({ count: 0, step: 1 });
368
367
 
369
368
  @computed get doubled() {
370
- return this.count * 2;
369
+ return this.state.count * 2;
371
370
  }
372
371
 
373
372
  increment() {
374
- this.count += this.step;
373
+ this.state.count += this.state.step;
375
374
  }
376
375
  }
377
376
 
378
377
  const counter = new Counter();
379
378
 
380
379
  effect(() => {
381
- console.log("Count:", counter.count, "Doubled:", counter.doubled);
380
+ console.log("Count:", counter.state.count, "Doubled:", counter.doubled);
382
381
  });
383
382
 
384
383
  counter.increment(); // Triggers the effect
385
384
  ```
386
385
 
387
- **Important:** Class instances require explicit `@observable` and `@computed` decorators. Properties without decorators are **not** reactive.
386
+ ### `Observable` base class
388
387
 
389
- ### Plain objects (implicit reactivity)
390
-
391
- Plain objects wrapped with `observable()` use implicit reactivity:
388
+ For class instances that need to be reactive, extending the `Observable` base class is the supported pattern. This ensures compatibility with ES `#private` fields and built-in brand checks because the Proxy is created in the base constructor, allowing derived field initializers (including `#private`) to run on the Proxy.
392
389
 
393
390
  ```ts
394
- import { observable, effect } from "r-state-tree";
391
+ import { Observable, effect } from "r-state-tree";
395
392
 
396
- const state = observable({ count: 0 });
393
+ class Counter extends Observable {
394
+ #count = 0; // Correctly works with #private
397
395
 
398
- effect(() => {
399
- console.log(state.count); // All properties are reactive
400
- });
396
+ get count() {
397
+ return this.#count;
398
+ }
401
399
 
402
- state.count++; // Triggers the effect
400
+ increment() {
401
+ this.#count++;
402
+ }
403
+ }
404
+
405
+ const counter = new Counter();
406
+ effect(() => console.log(counter.count));
407
+ counter.increment();
403
408
  ```
404
409
 
405
- ### Shallow and signal observables
410
+ > [!IMPORTANT] > **Why `extends Observable`?**
411
+ > ES `#private` fields are brand-checked. If you proxy a class instance _after_ it has been created (post-hoc proxying), or if you return a proxy from a standard constructor, the private state is installed on the original `this`, but methods run with the Proxy as `this`, causing `TypeError: Cannot read private member`.
412
+ >
413
+ > `extends Observable` solves this by returning the Proxy from `super()`, so derived classes initialize their private fields directly on the Proxy receiver.
406
414
 
407
- Control the depth of reactivity with `@observable.shallow` and `@observable.signal`:
415
+ ### Limitations of `observable(instance)`
408
416
 
409
- | Decorator | Container Observable? | Values Observable? | Triggers on... |
410
- |-----------|----------------------|-------------------|----------------|
411
- | `@observable` | ✅ Yes | ✅ Yes (deep) | mutations + assignment |
412
- | `@observable.shallow` | ✅ Yes | ❌ No | mutations + assignment |
413
- | `@observable.signal` | ❌ No | ❌ No | assignment only |
417
+ The `observable()` function is selective about what it proxies. It wraps **supported containers** only:
418
+
419
+ - Plain objects
420
+ - Arrays
421
+ - Maps and Sets
422
+ - Dates
423
+
424
+ **Functions are not observable containers**. Calling `observable(fn)` returns the function unchanged with a dev-mode warning. However, functions stored as properties on observable objects are still automatically **batched as actions** when called—this existing behavior is preserved, just not via `observable(fn)` directly.
425
+
426
+ If you pass an arbitrary class instance, built-in (like `URL`, `RegExp`, `Promise`, or DOM objects), or frozen object to `observable()`, it will **return the object unchanged** and emit a warning in development mode.
427
+
428
+ This design prevents “silent failure” where a proxied object appears to work but throws `TypeError` when accessing private members or internal slots. For your own classes, use `extends Observable`. For third-party or built-in objects, use composition:
414
429
 
415
430
  ```ts
416
- import { Observable, observable, effect, isObservable } from "r-state-tree";
431
+ // Post-hoc proxying - returns raw URL, not a proxy
432
+ const url = observable(new URL("..."));
417
433
 
418
- class DataStore extends Observable {
419
- // Deep observable - items pushed are also observable
420
- @observable deepItems: { value: number }[] = [];
434
+ // Composition - wrap a container instead
435
+ const state = observable({ url: new URL("...") });
436
+ ```
421
437
 
422
- // Shallow - container is observable, but pushed items are NOT
423
- @observable.shallow shallowItems: { value: number }[] = [];
438
+ Wrap values with `observable()` for reactivity. Collections (arrays, maps, sets) track mutations:
424
439
 
425
- // Signal - only assignment triggers, mutations do not
426
- @observable.signal signalItems: number[] = [];
440
+ ```ts
441
+ import { observable, effect, isObservable } from "r-state-tree";
442
+
443
+ class DataStore {
444
+ // Wrap state with observable() for reactivity
445
+ state = observable({ count: 0 });
446
+
447
+ // Wrap array with observable() to track push/pop/splice etc.
448
+ items = observable([]);
427
449
  }
428
450
 
429
451
  const store = new DataStore();
430
452
 
431
- // Shallow: items pushed are NOT observable (safe for structuredClone)
432
- store.shallowItems.push({ value: 1 });
433
- console.log(isObservable(store.shallowItems[0])); // false
453
+ effect(() => {
454
+ console.log("Items length:", store.items.length);
455
+ });
434
456
 
435
- // Signal: mutations don't trigger effects
436
- effect(() => store.signalItems.length);
437
- store.signalItems.push(1); // Does NOT trigger effect
438
- store.signalItems = [1, 2]; // DOES trigger effect
457
+ store.items.push({ value: 1 }); // Triggers effect
458
+ console.log(isObservable(store.items[0])); // false - shallow by default
439
459
  ```
440
460
 
441
- **Use cases:**
442
- - `@observable.shallow`: Store external data that may need `structuredClone()`, or when you want change detection on the container but not deep reactivity
443
- - `@observable.signal`: Maximum performance when you're replacing values rather than mutating them
461
+ ### Shallow behavior
462
+
463
+ All observables are **shallow by default**. Only the container's own properties are tracked—nested values are NOT wrapped:
464
+
465
+ - Collections (Arrays, Maps, Sets) wrapped with `observable()` track mutations
466
+ - Plain objects assigned to properties are NOT wrapped (helps preserve `structuredClone` compatibility for stored values)
467
+ - Nested object properties do NOT trigger effects unless explicitly wrapped
468
+
469
+ **Mental model: reactive property, explicit reactive value**
470
+
471
+ - Reading/writing a property on an observable container (including Stores/Models) is reactive.
472
+ - The _value_ you store is not auto-wrapped. If you store a plain object/array, mutating inside it won’t trigger reactions; wrap nested values with `observable()` (or use `signal()`), or use `toObservableTree` for a one-time deep wrap of an existing JSON-like structure.
444
473
 
445
- Models support the same modifiers with `@state`:
474
+ **What gets tracked in shallow mode:**
475
+
476
+ | Expression | Tracked? | Why |
477
+ | ----------------------- | ------------- | ------------------------------------------------ |
478
+ | `data.nested` | ✅ Yes | Property access on the observable container |
479
+ | `data.nested.value` | ❌ No | `data.nested` is a plain object, not observable |
480
+ | `data.nested = { ... }` | ✅ Triggers | Reassigns a property on the observable container |
481
+ | `data.nested.value = 2` | ❌ No trigger | Mutates a plain object; container unchanged |
446
482
 
447
483
  ```ts
448
- class M extends Model {
449
- @state.shallow items: { value: number }[] = []; // Container tracked, items not
450
- @state.signal data: SomeType = null; // Only assignment tracked
451
- }
484
+ const data = observable({ nested: { value: 1 } });
485
+
486
+ effect(() => {
487
+ data.nested.value; // Reads `data.nested` (tracked), then reads `.value` (not tracked)
488
+ });
489
+
490
+ data.nested.value = 2; // Does NOT trigger — mutating a plain object
491
+ data.nested = { value: 3 }; // DOES trigger — reassigning a property on the observable
492
+ ```
493
+
494
+ To make `nested` reactive, wrap it explicitly:
495
+
496
+ ```ts
497
+ const data = observable({ nested: observable({ value: 1 }) });
498
+ data.nested.value = 2; // Now triggers — `nested` is also observable
499
+ ```
500
+
501
+ ### Plain objects (implicit reactivity)
502
+
503
+ Plain objects wrapped with `observable()` use implicit reactivity:
504
+
505
+ ```ts
506
+ import { observable, effect } from "r-state-tree";
507
+
508
+ const state = observable({ count: 0 });
509
+
510
+ effect(() => {
511
+ console.log(state.count); // All properties are reactive
512
+ });
513
+
514
+ state.count++; // Triggers the effect
452
515
  ```
453
516
 
454
517
  ## Observables (low‑level)
455
518
 
456
519
  Create reactive structures outside Stores/Models. Supported: Objects, Arrays, Map, Set, WeakMap, WeakSet.
457
520
 
458
- - Track reads with `effect`/`reaction`. Nested values are tracked when you access them inside your effects.
459
- - Access raw values via `source(observable)`; check if something is reactive with `isObservable(value)`.
521
+ - Track reads with `effect`/`reaction`. Observables are **shallow**: reads are tracked on the observable container, but nested object mutations do **not** trigger unless you explicitly wrap nested values with `observable()` (or use signals).
522
+ - Access backing values via `source(value)`; check if something is reactive with `isObservable(value)`.
523
+ - Rule of thumb: `source(...)` returns the backing data, **not** a sanitizer—it's only proxy-free if you didn't manually seed proxies into the backing source.
460
524
  - Arrays: reading specific indices (`arr[i]`) or `length` tracks those; common mutators (`push/pop/shift/unshift/splice/reverse/sort/fill`) are reactive; non-index and symbol keys are not reactive.
461
525
 
526
+ ### `source()` and structuredClone
527
+
528
+ `source(x)` returns the backing value behind an observable proxy/container. It is **not** a “proxy stripper”.
529
+
530
+ **One-way rule (important):**
531
+
532
+ - If you mutate through the observable wrapper (e.g. `obj.prop = observable(child)`), r-state-tree stores the **raw backing value** in `source(obj).prop` (unwrap-on-write).
533
+ - If you manually mutate backing sources yourself (e.g. `source(obj).prop = observable(child)`), r-state-tree does **not** sanitize or rewrite your data. In that case, `source(...)` may contain proxies.
534
+
535
+ If you need to pass values to APIs that require cloneable data (e.g. `structuredClone`, `postMessage`), avoid seeding observable proxies into backing sources. Prefer reading via `source(...)` and keep your stored values plain/cloneable.
536
+
462
537
  ```ts
463
538
  import { observable, effect, computed, reaction } from "r-state-tree";
464
539
 
465
540
  // Object
466
- const state = observable({ count: 0, nested: { value: 1 } });
541
+ const state = observable({ count: 0, nested: observable({ value: 1 }) });
467
542
 
468
543
  effect(() => {
469
544
  // tracks reads
@@ -506,10 +581,86 @@ reaction(
506
581
  state.count++;
507
582
  ```
508
583
 
584
+ ### Derived collections and identity preservation
585
+
586
+ Native methods that return copies of a collection—such as `Array.prototype.slice`, `filter`, and `concat`, or `Set.prototype.union` and `intersection`—behave according to the **Explicit Architecture**:
587
+
588
+ - **Raw Return**: The returned container is a plain JavaScript object (raw, non-observable).
589
+ - **Identity Preservation**: Each element in the returned container maintains the same identity it had in the observable source. If an element was a proxy (because it was explicitly owned), it remains a proxy in the raw result.
590
+
591
+ This ensures that "derived" state is not automatically made reactive, while still allowing observers to maintain reference stability with existing objects in your state tree.
592
+
593
+ ```ts
594
+ const item = observable({ id: 1 });
595
+ const arr = observable([item, { id: 2 }]);
596
+
597
+ // slice() returns a plain array
598
+ const sliced = arr.slice();
599
+ isObservable(sliced); // false
600
+
601
+ // Identity is preserved
602
+ sliced[0] === item; // true (same proxy identity)
603
+
604
+ // If you want the result to be reactive, wrap it explicitly
605
+ const reactiveSlice = observable(arr.slice());
606
+ ```
607
+
608
+ ### Recursively wrapping nested values (`toObservableTree`)
609
+
610
+ By default, `observable()` is **shallow**: only the top-level container is wrapped, and nested objects/arrays are not. This is intentional for performance and `structuredClone` compatibility.
611
+
612
+ `toObservableTree` performs a **one-time initial pass** that wraps all existing nested plain objects and arrays with `observable()`. After the initial wrap, the returned observables behave exactly like normal shallow observables — new assignments are **not** auto-wrapped.
613
+
614
+ ```ts
615
+ import { toObservableTree, effect, source, isObservable } from "r-state-tree";
616
+
617
+ // Initial pass wraps all existing nested plain objects/arrays
618
+ const tree = toObservableTree({
619
+ user: { name: "Alice", tags: ["admin", "active"] },
620
+ settings: { theme: "dark" },
621
+ });
622
+
623
+ effect(() => {
624
+ // Existing nested values are observable and tracked
625
+ console.log(tree.user.name);
626
+ console.log(tree.user.tags[0]);
627
+ });
628
+
629
+ // Mutations to initially-wrapped values trigger effects
630
+ tree.user.name = "Bob"; // triggers
631
+ tree.user.tags[0] = "superadmin"; // triggers
632
+
633
+ // NEW assignments are NOT auto-wrapped (normal shallow behavior)
634
+ tree.newProp = { foo: 1 };
635
+ isObservable(tree.newProp); // false — not wrapped
636
+
637
+ // Source is proxy-free and clonable *as long as you don't manually seed proxies*
638
+ // into backing data structures. r-state-tree unwraps proxies on writes performed
639
+ // through observable containers, but it does not sanitize user-mutated backing sources.
640
+ const snapshot = structuredClone(source(tree));
641
+ ```
642
+
643
+ **Key behavior:**
644
+
645
+ - **One-time pass**: Only values present at call time are wrapped. New assignments afterward behave like normal `observable()` (shallow).
646
+ - **Not MobX-style "deep"**: This does NOT change the observable's behavior. It's just a convenience for wrapping an existing structure upfront.
647
+
648
+ **When to use:**
649
+
650
+ - Hydrating API/JSON responses where you want all nested values observable from the start
651
+ - Cases where manually wrapping each nested object would be tedious
652
+
653
+ **Constraints:**
654
+
655
+ - Input must be JSON-like and **acyclic** (no circular references)
656
+ - Only plain objects and arrays are wrapped; other types (Map, Set, Date, class instances, RegExp, Error, etc.) are left as-is and not traversed
657
+
509
658
  ## Models and snapshots
510
659
 
511
660
  Models capture persistent state with snapshot utilities.
512
661
 
662
+ **Important:** `@state` is **shallow-reactive** at the property level (assignments track), but values are **not auto-wrapped**. If you need nested mutations to be reactive (and to invalidate snapshot caches on in-place mutation), store `observable()` containers or `signal()` values inside `@state`.
663
+
513
664
  ```ts
514
665
  import {
515
666
  Model,
@@ -536,6 +687,94 @@ todo.title = "Learn r-state-tree";
536
687
  stop();
537
688
  ```
538
689
 
690
+ ### Snapshot data contract
691
+
692
+ **Snapshots are JSON-only**: they contain primitives, arrays, plain objects, and Dates (serialized as ISO strings).
693
+
694
+ - **Primitives**: `string`, `number`, `boolean`, `null`, `undefined` pass through.
695
+ - **Arrays**: recursively cloned.
696
+ - **Plain objects**: recursively cloned (prototype must be `Object.prototype` or `null`).
697
+ - **Dates**: serialize to ISO strings (e.g., `"2024-01-15T10:30:00.000Z"`).
698
+ - **Signals**: serialize to their current `.value` (recursively cloned).
699
+ - **Map/Set/WeakMap/WeakSet and other non-plain objects are rejected** with a descriptive error. Convert them to plain structures before storing in `@state`.
700
+
701
+ ```ts
702
+ class Event extends Model {
703
+ @state title = "Meeting";
704
+ @state createdAt = new Date(); // Date → ISO string in snapshot
705
+ }
706
+
707
+ const event = Event.create();
708
+ toSnapshot(event);
709
+ // { title: "Meeting", createdAt: "2024-01-15T10:30:00.000Z" }
710
+ ```
711
+
712
+ If you store a `Map` or class instance in `@state`, snapshotting will throw:
713
+
714
+ ```ts
715
+ class M extends Model {
716
+ @state cache = new Map(); // ❌ Will throw on toSnapshot()
717
+ }
718
+ // Error: r-state-tree: snapshots do not support Map at path "cache". ...
719
+ ```
720
+
721
+ Convert to a plain structure instead:
722
+
723
+ ```ts
724
+ class M extends Model {
725
+ @state cache: Record<string, unknown> = {}; // ✅ Plain object
726
+ }
727
+ ```
728
+
729
+ ### Snapshot invalidation rules
730
+
731
+ Snapshots are **memoized computeds**. Once a snapshot is observed (via `onSnapshot`, `onSnapshotDiff`, or `toSnapshot`), subsequent calls return the cached value unless a reactive dependency changes.
732
+
733
+ Because snapshots are **memoized computeds** and observables are **shallow**, the snapshot cache is invalidated only by:
734
+
735
+ 1. **Reassigning** the `@state` field itself.
736
+ 2. **Mutating observable containers** (`observable()`) or **signals** (`signal()`) stored in the field.
737
+
738
+ **Rule:** Treat raw `@state` values (plain objects/arrays) as immutable. If you mutate them in place without reassignment, the snapshot cache goes stale—`onSnapshot` won't fire and `toSnapshot` returns the old cached value.
739
+
740
+ ```ts
741
+ class M extends Model {
742
+ @state tags: string[] = [];
743
+
744
+ // ❌ In-place mutation — snapshot cache goes stale
745
+ addTagBroken(tag: string) {
746
+ this.tags.push(tag);
747
+ }
748
+
749
+ // ✅ Reassign — invalidates cache, onSnapshot fires
750
+ addTagReassign(tag: string) {
751
+ this.tags = [...this.tags, tag];
752
+ }
753
+ }
754
+ ```
755
+
756
+ If you need in-place mutations **and** snapshot updates, wrap the value in `observable()` or use `signal()`:
757
+
758
+ ```ts
759
+ class M extends Model {
760
+ // ✅ observable() container — in-place mutations invalidate cache
761
+ @state items: { id: number }[] = observable([]);
762
+
763
+ addItem(id: number) {
764
+ this.items.push({ id }); // onSnapshot fires
765
+ }
766
+ }
767
+
768
+ class Counter extends Model {
769
+ // ✅ signal() — .value updates invalidate cache
770
+ @state count = signal(0);
771
+
772
+ increment() {
773
+ this.count.value++; // onSnapshot fires
774
+ }
775
+ }
776
+ ```
777
+
539
778
  ### Snapshots and persistence
540
779
 
541
780
  - Snapshots capture Models (not Stores).
@@ -685,8 +924,10 @@ class ContainerModel extends Model {
685
924
  - Context
686
925
  - `createContext`, type `Context`
687
926
  - Reactivity and observables
688
- - `observable`, `computed`, `effect`, `reaction`, `batch`, `untracked`, `Observable`
927
+ - `observable`, `computed`, `effect`, `reaction`, `batch`, `untracked`
689
928
  - Utilities: `isObservable`, `source`, `reportObserved`, `reportChanged`
929
+ - Advanced
930
+ - `toObservableTree` — recursively wrap nested values in a JSON-like structure
690
931
  - Signals interop
691
932
  - `signal`, `getSignal`, types `Signal`, `ReadonlySignal`
692
933
 
@@ -694,7 +935,7 @@ class ContainerModel extends Model {
694
935
 
695
936
  r-state-tree is built on `@preact/signals-core`. You can interoperate with signals directly:
696
937
 
697
- - Per-property signals via `$prop` on observable objects (including Stores/Models), or `getSignal(obj, key)`.
938
+ - Per-property signals via `getSignal(obj, key)`.
698
939
  - Re-exported utilities: `signal`, `computed`, `effect`, `batch`, `untracked`, and types `Signal`, `ReadonlySignal`.
699
940
 
700
941
  ```ts
@@ -702,17 +943,14 @@ import { observable, effect, getSignal } from "r-state-tree";
702
943
 
703
944
  const state = observable({ count: 0 });
704
945
 
705
- // Either form returns a Signal<number>
706
- const s1 = state.$count;
707
- const s2 = getSignal(state, "count");
946
+ const countSignal = getSignal(state, "count");
708
947
 
709
948
  effect(() => {
710
- // Use s1.value (or s2.value) in your UI binding
711
- console.log("count:", s1.value);
949
+ console.log("count:", countSignal.value);
712
950
  });
713
951
 
714
952
  // Update via signal or through the object
715
- s1.value = 1;
953
+ countSignal.value = 1;
716
954
  state.count = 2;
717
955
  ```
718
956
 
@@ -724,18 +962,20 @@ state.count = 2;
724
962
  ```ts
725
963
  // Preact
726
964
  function TodoView({ store }: { store: TodoStore }) {
727
- return <h1>{store.$title.value}</h1>;
965
+ const titleSignal = getSignal(store, "title");
966
+ return <h1>{titleSignal.value}</h1>;
728
967
  }
729
968
 
730
969
  // React
731
970
  import { useSignals } from "@preact/signals-react/runtime";
732
971
  function TodoView({ store }: { store: TodoStore }) {
733
972
  useSignals();
734
- return <h1>{store.$title.value}</h1>;
973
+ const titleSignal = getSignal(store, "title");
974
+ return <h1>{titleSignal.value}</h1>;
735
975
  }
736
976
  ```
737
977
 
738
- You can also use `getSignal(store, "title")` instead of `$title`. Use the observers/renderers provided by the signals bindings for your UI library; r-state-tree will participate automatically because Stores/Models are signal-backed.
978
+ Use the observers/renderers provided by the signals bindings for your UI library; r-state-tree will participate automatically because Stores/Models are signal-backed.
739
979
 
740
980
  ### Identifier and reference rules
741
981
 
@@ -798,7 +1038,8 @@ Do:
798
1038
  - Delegate from Stores to Models for domain changes
799
1039
  - Use `@child` for child stores (getter-based)
800
1040
  - Use stable `key` values for child stores
801
- - Mutate model arrays/maps/sets in place
1041
+ - Mutate **`@child` model collections** and **`observable()` containers** in place (push/splice/set/add/etc.)
1042
+ - Treat **raw `@state` arrays/objects** as immutable: use reassignment so snapshots stay up to date (or store an `observable()` container / `signal()` inside `@state`)
802
1043
 
803
1044
  Don’t:
804
1045
 
@@ -884,7 +1125,9 @@ const off = onSnapshot(m, (snap) =>
884
1125
  ## Common pitfalls
885
1126
 
886
1127
  - Forgetting stable keys for `@child` arrays causes identity churn.
887
- - Assuming deep reactivity on undecorated fields of plain classes; use `@observable` for `Observable` classes, or use Stores/Models.
1128
+ - Assuming **deep** reactivity: nested plain objects/arrays are not reactive unless you explicitly wrap them (or use `toObservableTree` for initial hydration).
1129
+ - Mutating raw `@state` arrays/objects in place (`push`, `obj.x = 1`) and expecting snapshots to update. Snapshots are memoized; use reassignment or store `observable()` containers / `signal()` values in `@state`.
1130
+ - Passing observable proxies to third‑party APIs that expect cloneable/serializable values (e.g. `structuredClone`). Use `source(value)` to get the backing value. It will be proxy-free for values written via r-state-tree’s observable APIs (unwrap-on-write), but `source(...)` is **not** guaranteed proxy-free if you manually seed proxies into backing sources.
888
1131
  - Creating child stores in constructors: `@child` must be on getters so identity and lifecycle can be managed by the framework.
889
1132
  - Passing `models` into child stores during mount can create a recursive mount loop. If a child needs parent models, create the child store/model inside `storeDidMount` instead of wiring it through `models` during the mount cycle.
890
1133
 
package/dist/index.d.ts CHANGED
@@ -3,8 +3,9 @@ import Store, { createStore, updateStore } from "./store/Store";
3
3
  import Model from "./model/Model";
4
4
  import { mount, unmount, onSnapshot, toSnapshot, applySnapshot, onSnapshotDiff } from "./api";
5
5
  import { createContext } from "./context";
6
- export { createStore, Store, Model, mount, unmount, updateStore, onSnapshot, onSnapshotDiff, toSnapshot, applySnapshot, createContext, };
7
- export { observable, computed, source, reportChanged, reportObserved, getSignal, isObservable, reaction, Signal, type ReadonlySignal, batch, untracked, effect, Observable, signal, } from "./observables";
6
+ import { toObservableTree } from "./toObservableTree";
7
+ export { createStore, Store, Model, mount, unmount, updateStore, onSnapshot, onSnapshotDiff, toSnapshot, applySnapshot, createContext, toObservableTree, };
8
+ export { observable, computed, source, reportChanged, reportObserved, getSignal, isObservable, reaction, Signal, type ReadonlySignal, batch, untracked, effect, signal, Observable, } from "./observables";
8
9
  export * from "./decorators";
9
- export type { Configuration, Snapshot, IdType, SnapshotDiff } from "./types";
10
+ export type { Configuration, Snapshot, SnapshotValue, IdType, SnapshotDiff, } from "./types";
10
11
  export type { Context } from "./context";
@@ -29,7 +29,7 @@ declare class ObservableListener<T> {
29
29
  notify(ev: MutationEvent<T>): void;
30
30
  }
31
31
  export declare class ChildModelsAdministration<T> extends ArrayAdministration<T> {
32
- set(index: number, newValue: T): void;
32
+ set(index: number, newValue: T): boolean;
33
33
  spliceWithArray(index: number, deleteCount?: number | undefined, newItems?: T[] | undefined): T[];
34
34
  }
35
35
  export {};
@@ -16,9 +16,11 @@ export declare class ModelAdministration extends PreactObjectAdministration<any>
16
16
  private modelsTraceUnsub;
17
17
  private writeInProgress;
18
18
  private computedSnapshot;
19
+ private snapshotAtom;
19
20
  private snapshotMap;
20
21
  private contextCache;
21
22
  parentName: PropertyKey | null;
23
+ private observeSnapshotValue;
22
24
  get parent(): ModelAdministration | null;
23
25
  set parent(value: ModelAdministration | null);
24
26
  setConfiguration(configurationGetter: () => ModelConfiguration<any>): void;
@@ -4,17 +4,19 @@ import { SignalMap } from "./internal/NodeMap";
4
4
  export declare class ArrayAdministration<T> extends Administration<T[]> {
5
5
  valuesMap: SignalMap<number>;
6
6
  keysAtom: AtomNode;
7
+ private explicitObservables;
8
+ private syncExplicitObservablesFromSource;
7
9
  static proxyTraps: ProxyHandler<Array<unknown>>;
8
10
  static methods: Partial<{
9
11
  [K in keyof typeof Array.prototype as (typeof Array.prototype)[K] extends Function ? K : never]: (typeof Array.prototype)[K];
10
12
  }>;
11
13
  constructor(source?: T[]);
12
- protected reportObserveDeep(): void;
13
14
  getNode(key?: number): unknown;
14
15
  get(index: number): T | undefined;
15
- set(index: number, newValue: T): void;
16
+ private _getEffectiveValue;
17
+ set(index: number, newValue: T): boolean;
16
18
  getArrayLength(): number;
17
- setArrayLength(newLength: number): void;
19
+ setArrayLength(input: unknown): boolean;
18
20
  spliceWithArray(index: number, deleteCount?: number, newItems?: T[]): T[];
19
21
  spliceItemsIntoValues(index: number, deleteCount: number, newItems: T[]): T[];
20
22
  onArrayChanged(lengthChanged?: boolean, index?: number, count?: number): void;
@@ -7,30 +7,26 @@ export declare class CollectionAdministration<K, V = K> extends Administration<C
7
7
  hasMap: AtomMap<K>;
8
8
  valuesMap: SignalMap<K>;
9
9
  keysAtom: AtomNode;
10
+ private isWeak;
11
+ private strongTracking;
12
+ private weakTracking;
10
13
  static proxyTraps: ProxyHandler<Set<unknown> | Map<unknown, unknown>>;
11
- static methods: {
12
- clear: (this: any) => unknown;
13
- forEach: (this: any) => unknown;
14
- has: (this: any) => unknown;
15
- add: (this: any) => unknown;
16
- set: (this: any) => unknown;
17
- get: (this: any) => unknown;
18
- delete: (this: any) => unknown;
19
- entries: (this: any) => unknown;
20
- keys: (this: any) => unknown;
21
- values: (this: any) => unknown;
22
- [Symbol.iterator]: (this: any) => unknown;
23
- };
14
+ static methods: Record<PropertyKey, Function>;
24
15
  constructor(source: Collection<K, V>);
16
+ private trackExplicitObservable;
17
+ private untrackExplicitObservable;
18
+ private hasExplicitObservable;
19
+ private getProxyVariant;
20
+ private getExistingKey;
25
21
  private hasEntry;
26
22
  private onCollectionChange;
27
- protected reportObserveDeep(): void;
28
23
  getNode(key?: K): unknown;
29
24
  clear(): void;
30
25
  forEach(callbackFn: (value: V, key: K, collection: Collection<K, V>) => void, thisArg?: unknown): void;
31
26
  get size(): number;
32
27
  add(value: K): this;
33
28
  delete(value: K): boolean;
29
+ private wrapSetValue;
34
30
  has(value: K): boolean;
35
31
  entries(): IterableIterator<[K, V]>;
36
32
  keys(): IterableIterator<K>;
@@ -1,6 +1,6 @@
1
- export { getAdministration, getSource, isObservable, getObservable, getObservableClassInstance, getInternalNode, createObservableWithCustomAdministration, } from "./internal/lookup";
1
+ export { getAdministration, getSource, isObservable, getObservable, getInternalNode, createObservableWithCustomAdministration, Observable, } from "./internal/lookup";
2
2
  export { ArrayAdministration } from "./array";
3
3
  export { CollectionAdministration } from "./collection";
4
4
  export { DateAdministration } from "./date";
5
5
  export { ObjectAdministration } from "./object";
6
- export { type AtomNode, reaction, type SignalNode, createAtom, batch, createComputed, createSignal, untracked, type ListenerNode, createListener, type ComputedNode, PreactObjectAdministration, getSignal, source, reportChanged, reportObserved, observable, computed, Signal, type ReadonlySignal, effect, signal, Observable, } from "./preact";
6
+ export { type AtomNode, reaction, type SignalNode, createAtom, batch, createComputed, createSignal, untracked, type ListenerNode, createListener, type ComputedNode, PreactObjectAdministration, getSignal, source, reportChanged, reportObserved, observable, computed, Signal, type ReadonlySignal, effect, signal, } from "./preact";
@@ -7,11 +7,10 @@ export declare class Administration<T extends object = any> {
7
7
  readonly atom: ObservedAtomNode;
8
8
  protected valuesMap?: SignalMap;
9
9
  protected isObserved: boolean;
10
- private forceObservedAtoms?;
10
+ private forceObservedAtom?;
11
11
  constructor(source: T);
12
12
  protected flushChange(): void;
13
13
  getNode(): unknown;
14
14
  reportChanged(): void;
15
- protected reportObserveDeep(): void;
16
- reportObserved(deep?: boolean): void;
15
+ reportObserved(): void;
17
16
  }