r-state-tree 0.4.7 → 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 +358 -28
- package/dist/index.d.ts +4 -3
- package/dist/model/ChildModelsAdministration.d.ts +1 -1
- package/dist/model/ModelAdministration.d.ts +2 -0
- package/dist/observables/array.d.ts +5 -3
- package/dist/observables/collection.d.ts +10 -14
- package/dist/observables/index.d.ts +2 -2
- package/dist/observables/internal/Administration.d.ts +2 -3
- package/dist/observables/internal/NodeMap.d.ts +1 -0
- package/dist/observables/internal/lookup.d.ts +3 -0
- package/dist/observables/object.d.ts +5 -4
- package/dist/observables/preact.d.ts +5 -15
- package/dist/r-state-tree.cjs +1034 -309
- package/dist/r-state-tree.js +1048 -320
- package/dist/store/StoreAdministration.d.ts +1 -1
- package/dist/toObservableTree.d.ts +1 -0
- package/dist/types.d.ts +5 -3
- package/dist/utils.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,12 +42,25 @@ TypeScript 5+ supports TC39 Stage 3 decorators.
|
|
|
42
42
|
- `useDefineForClassFields: true` is recommended with modern toolchains targeting ES2022.
|
|
43
43
|
- The library includes a `Symbol.metadata` polyfill via `@tsmetadata/polyfill`.
|
|
44
44
|
|
|
45
|
+
### Vite / esbuild config (SSR)
|
|
46
|
+
|
|
47
|
+
When using Vite or esbuild for SSR, ensure the target is set to `es2022` to support Stage 3 decorators:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// vite.config.ts
|
|
51
|
+
export default defineConfig({
|
|
52
|
+
esbuild: {
|
|
53
|
+
target: "es2022",
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
45
58
|
## Core concepts
|
|
46
59
|
|
|
47
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()`.
|
|
48
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`.
|
|
49
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.
|
|
50
|
-
- Reactivity: powered by signals. Use
|
|
63
|
+
- Reactivity: powered by signals. Use `observable()`, `@computed`, `effect`, `reaction`, `batch`, and `untracked` for precise updates.
|
|
51
64
|
|
|
52
65
|
## Separation of concerns
|
|
53
66
|
|
|
@@ -339,39 +352,151 @@ class ListStore extends Store {
|
|
|
339
352
|
|
|
340
353
|
## Mutability rules
|
|
341
354
|
|
|
342
|
-
- Models:
|
|
343
|
-
- Stores: store fields are reactive
|
|
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.
|
|
344
357
|
|
|
345
358
|
## Observable classes
|
|
346
359
|
|
|
347
|
-
For reactive class instances outside the Model/Store system,
|
|
360
|
+
For reactive class instances outside the Model/Store system, wrap state with `observable()`:
|
|
348
361
|
|
|
349
362
|
```ts
|
|
350
|
-
import {
|
|
363
|
+
import { observable, computed, effect } from "r-state-tree";
|
|
351
364
|
|
|
352
|
-
class Counter
|
|
353
|
-
|
|
354
|
-
@observable step = 1;
|
|
365
|
+
class Counter {
|
|
366
|
+
state = observable({ count: 0, step: 1 });
|
|
355
367
|
|
|
356
368
|
@computed get doubled() {
|
|
357
|
-
return this.count * 2;
|
|
369
|
+
return this.state.count * 2;
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
increment() {
|
|
361
|
-
this.count += this.step;
|
|
373
|
+
this.state.count += this.state.step;
|
|
362
374
|
}
|
|
363
375
|
}
|
|
364
376
|
|
|
365
377
|
const counter = new Counter();
|
|
366
378
|
|
|
367
379
|
effect(() => {
|
|
368
|
-
console.log("Count:", counter.count, "Doubled:", counter.doubled);
|
|
380
|
+
console.log("Count:", counter.state.count, "Doubled:", counter.doubled);
|
|
369
381
|
});
|
|
370
382
|
|
|
371
383
|
counter.increment(); // Triggers the effect
|
|
372
384
|
```
|
|
373
385
|
|
|
374
|
-
|
|
386
|
+
### `Observable` base class
|
|
387
|
+
|
|
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.
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
import { Observable, effect } from "r-state-tree";
|
|
392
|
+
|
|
393
|
+
class Counter extends Observable {
|
|
394
|
+
#count = 0; // Correctly works with #private
|
|
395
|
+
|
|
396
|
+
get count() {
|
|
397
|
+
return this.#count;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
increment() {
|
|
401
|
+
this.#count++;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const counter = new Counter();
|
|
406
|
+
effect(() => console.log(counter.count));
|
|
407
|
+
counter.increment();
|
|
408
|
+
```
|
|
409
|
+
|
|
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.
|
|
414
|
+
|
|
415
|
+
### Limitations of `observable(instance)`
|
|
416
|
+
|
|
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:
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
// ❌ Post-hoc proxying - returns raw URL, not a proxy
|
|
432
|
+
const url = observable(new URL("..."));
|
|
433
|
+
|
|
434
|
+
// ✅ Composition - wrap a container instead
|
|
435
|
+
const state = observable({ url: new URL("...") });
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Wrap values with `observable()` for reactivity. Collections (arrays, maps, sets) track mutations:
|
|
439
|
+
|
|
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([]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const store = new DataStore();
|
|
452
|
+
|
|
453
|
+
effect(() => {
|
|
454
|
+
console.log("Items length:", store.items.length);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
store.items.push({ value: 1 }); // Triggers effect
|
|
458
|
+
console.log(isObservable(store.items[0])); // false - shallow by default
|
|
459
|
+
```
|
|
460
|
+
|
|
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.
|
|
473
|
+
|
|
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 |
|
|
482
|
+
|
|
483
|
+
```ts
|
|
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
|
+
```
|
|
375
500
|
|
|
376
501
|
### Plain objects (implicit reactivity)
|
|
377
502
|
|
|
@@ -393,15 +518,27 @@ state.count++; // Triggers the effect
|
|
|
393
518
|
|
|
394
519
|
Create reactive structures outside Stores/Models. Supported: Objects, Arrays, Map, Set, WeakMap, WeakSet.
|
|
395
520
|
|
|
396
|
-
- Track reads with `effect`/`reaction`.
|
|
397
|
-
- Access
|
|
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.
|
|
398
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.
|
|
399
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
|
+
|
|
400
537
|
```ts
|
|
401
538
|
import { observable, effect, computed, reaction } from "r-state-tree";
|
|
402
539
|
|
|
403
540
|
// Object
|
|
404
|
-
const state = observable({ count: 0, nested: { value: 1 } });
|
|
541
|
+
const state = observable({ count: 0, nested: observable({ value: 1 }) });
|
|
405
542
|
|
|
406
543
|
effect(() => {
|
|
407
544
|
// tracks reads
|
|
@@ -444,10 +581,86 @@ reaction(
|
|
|
444
581
|
state.count++;
|
|
445
582
|
```
|
|
446
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
|
+
|
|
447
658
|
## Models and snapshots
|
|
448
659
|
|
|
449
660
|
Models capture persistent state with snapshot utilities.
|
|
450
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
|
+
|
|
451
664
|
```ts
|
|
452
665
|
import {
|
|
453
666
|
Model,
|
|
@@ -474,6 +687,94 @@ todo.title = "Learn r-state-tree";
|
|
|
474
687
|
stop();
|
|
475
688
|
```
|
|
476
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
|
+
|
|
477
778
|
### Snapshots and persistence
|
|
478
779
|
|
|
479
780
|
- Snapshots capture Models (not Stores).
|
|
@@ -623,8 +924,10 @@ class ContainerModel extends Model {
|
|
|
623
924
|
- Context
|
|
624
925
|
- `createContext`, type `Context`
|
|
625
926
|
- Reactivity and observables
|
|
626
|
-
- `observable`, `computed`, `effect`, `reaction`, `batch`, `untracked
|
|
927
|
+
- `observable`, `computed`, `effect`, `reaction`, `batch`, `untracked`
|
|
627
928
|
- Utilities: `isObservable`, `source`, `reportObserved`, `reportChanged`
|
|
929
|
+
- Advanced
|
|
930
|
+
- `toObservableTree` — recursively wrap nested values in a JSON-like structure
|
|
628
931
|
- Signals interop
|
|
629
932
|
- `signal`, `getSignal`, types `Signal`, `ReadonlySignal`
|
|
630
933
|
|
|
@@ -632,7 +935,7 @@ class ContainerModel extends Model {
|
|
|
632
935
|
|
|
633
936
|
r-state-tree is built on `@preact/signals-core`. You can interoperate with signals directly:
|
|
634
937
|
|
|
635
|
-
- Per-property signals via
|
|
938
|
+
- Per-property signals via `getSignal(obj, key)`.
|
|
636
939
|
- Re-exported utilities: `signal`, `computed`, `effect`, `batch`, `untracked`, and types `Signal`, `ReadonlySignal`.
|
|
637
940
|
|
|
638
941
|
```ts
|
|
@@ -640,17 +943,14 @@ import { observable, effect, getSignal } from "r-state-tree";
|
|
|
640
943
|
|
|
641
944
|
const state = observable({ count: 0 });
|
|
642
945
|
|
|
643
|
-
|
|
644
|
-
const s1 = state.$count;
|
|
645
|
-
const s2 = getSignal(state, "count");
|
|
946
|
+
const countSignal = getSignal(state, "count");
|
|
646
947
|
|
|
647
948
|
effect(() => {
|
|
648
|
-
|
|
649
|
-
console.log("count:", s1.value);
|
|
949
|
+
console.log("count:", countSignal.value);
|
|
650
950
|
});
|
|
651
951
|
|
|
652
952
|
// Update via signal or through the object
|
|
653
|
-
|
|
953
|
+
countSignal.value = 1;
|
|
654
954
|
state.count = 2;
|
|
655
955
|
```
|
|
656
956
|
|
|
@@ -662,18 +962,20 @@ state.count = 2;
|
|
|
662
962
|
```ts
|
|
663
963
|
// Preact
|
|
664
964
|
function TodoView({ store }: { store: TodoStore }) {
|
|
665
|
-
|
|
965
|
+
const titleSignal = getSignal(store, "title");
|
|
966
|
+
return <h1>{titleSignal.value}</h1>;
|
|
666
967
|
}
|
|
667
968
|
|
|
668
969
|
// React
|
|
669
970
|
import { useSignals } from "@preact/signals-react/runtime";
|
|
670
971
|
function TodoView({ store }: { store: TodoStore }) {
|
|
671
972
|
useSignals();
|
|
672
|
-
|
|
973
|
+
const titleSignal = getSignal(store, "title");
|
|
974
|
+
return <h1>{titleSignal.value}</h1>;
|
|
673
975
|
}
|
|
674
976
|
```
|
|
675
977
|
|
|
676
|
-
|
|
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.
|
|
677
979
|
|
|
678
980
|
### Identifier and reference rules
|
|
679
981
|
|
|
@@ -736,7 +1038,8 @@ Do:
|
|
|
736
1038
|
- Delegate from Stores to Models for domain changes
|
|
737
1039
|
- Use `@child` for child stores (getter-based)
|
|
738
1040
|
- Use stable `key` values for child stores
|
|
739
|
-
- Mutate model
|
|
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`)
|
|
740
1043
|
|
|
741
1044
|
Don’t:
|
|
742
1045
|
|
|
@@ -822,8 +1125,35 @@ const off = onSnapshot(m, (snap) =>
|
|
|
822
1125
|
## Common pitfalls
|
|
823
1126
|
|
|
824
1127
|
- Forgetting stable keys for `@child` arrays causes identity churn.
|
|
825
|
-
- Assuming deep reactivity
|
|
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.
|
|
826
1131
|
- Creating child stores in constructors: `@child` must be on getters so identity and lifecycle can be managed by the framework.
|
|
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.
|
|
1133
|
+
|
|
1134
|
+
### Circular store/model creation
|
|
1135
|
+
|
|
1136
|
+
When child stores are created during mount with `models` that point back into the parent, it is easy to trigger an endless mount loop. The runtime now guards this by throwing a descriptive error (for example, `detected circular store/model creation while mounting ParentStore -> ChildStore.loop -> ...`). If you see this, move child creation into `storeDidMount` or break the cycle so that models are produced after the parent finishes mounting.
|
|
1137
|
+
|
|
1138
|
+
## LLM implementation checklist
|
|
1139
|
+
|
|
1140
|
+
- Do not thread context data via props. Provide contexts at the parent and consume them in children. Passing `getX` callbacks for resource/page/video/skill is a red flag—consume contexts instead.
|
|
1141
|
+
- Avoid aggregating contexts into a single `ctx` object; consume where needed or expose small, focused getters.
|
|
1142
|
+
- Only create stores in three places: root, `@child` getters, or immediately before mounting. Avoid standalone factory helpers; inline `createStore` in `@child` getters.
|
|
1143
|
+
- Do not wrap `createStore` calls with `as Record<string, unknown>`; fix typing instead.
|
|
1144
|
+
- Domain/persistent state belongs in Models; UI/app orchestration in Stores. Keep UI terms out of domain models—name domain concepts (e.g., `ChatThreadsModel`).
|
|
1145
|
+
- If state is persistent/rehydratable, model it and derive stores from the model; keep purely view/ephemeral state in stores.
|
|
1146
|
+
- Use r-state-tree snapshots (`toSnapshot`/`applySnapshot`) instead of hand-rolled `serialize`/`rehydrate` unless a different shape is required.
|
|
1147
|
+
- Keep `onPersist` only when syncing store state into a backing model; otherwise prefer snapshot listeners.
|
|
1148
|
+
- Pure, stateless helpers belong in utility modules, not as store methods. If a method does not touch `this`, extract it; keep coupled helpers in the store.
|
|
1149
|
+
- Provide stable, rarely changing resource/view/video/page data via context (resourceId, resourceType, totalPages, current page/display mode, page offsets, document title, skill/detail, video info). Children should consume context directly.
|
|
1150
|
+
- Use `@child` getters to create child stores with stable keys; avoid constructor creation. Pass only what the child needs; avoid prop drilling context.
|
|
1151
|
+
- Eliminate blanket casts; fix types and let inference work. Avoid `any`.
|
|
1152
|
+
- For `createStore` props, extend `Record<string, unknown>` only if needed; otherwise rely on proper prop types and context.
|
|
1153
|
+
- Avoid barrel files if they cause import confusion; prefer direct imports.
|
|
1154
|
+
- Keep file boundaries clean: one model per file; avoid piling multiple models together.
|
|
1155
|
+
- Do not shadow `props` or use constructors for work better suited to `storeDidMount`.
|
|
1156
|
+
- Use `@model` for injected models; `@child` for child stores; stable keys for arrays.
|
|
827
1157
|
|
|
828
1158
|
## Typing recipes
|
|
829
1159
|
|
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
|
-
|
|
7
|
-
export {
|
|
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):
|
|
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
|
-
|
|
16
|
+
private _getEffectiveValue;
|
|
17
|
+
set(index: number, newValue: T): boolean;
|
|
16
18
|
getArrayLength(): number;
|
|
17
|
-
setArrayLength(
|
|
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,
|
|
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,
|
|
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
|
|
10
|
+
private forceObservedAtom?;
|
|
11
11
|
constructor(source: T);
|
|
12
12
|
protected flushChange(): void;
|
|
13
13
|
getNode(): unknown;
|
|
14
14
|
reportChanged(): void;
|
|
15
|
-
|
|
16
|
-
reportObserved(deep?: boolean): void;
|
|
15
|
+
reportObserved(): void;
|
|
17
16
|
}
|
|
@@ -13,6 +13,7 @@ declare class NodeMap<K = unknown, GraphNode extends AtomNode | SignalNode<unkno
|
|
|
13
13
|
getOrCreate(key: K, value?: unknown): GraphNode;
|
|
14
14
|
reportObserved(key: K, value?: unknown): void;
|
|
15
15
|
reportChanged(key: K, value?: unknown): void;
|
|
16
|
+
keys(): Iterable<unknown>;
|
|
16
17
|
}
|
|
17
18
|
export declare class AtomMap<K = unknown> extends NodeMap<K, AtomNode> {
|
|
18
19
|
protected createNode(): AtomNode;
|
|
@@ -7,6 +7,9 @@ export declare function getAdministration<T extends object>(obj: T): T extends S
|
|
|
7
7
|
export declare function getSource<T>(obj: T): T;
|
|
8
8
|
export declare function getAction<T extends Function>(fn: T): T;
|
|
9
9
|
export declare function getObservableClassInstance<T extends object>(value: T): T;
|
|
10
|
+
export declare class Observable {
|
|
11
|
+
constructor();
|
|
12
|
+
}
|
|
10
13
|
export declare function getObservableIfExists<T>(value: T): T | undefined;
|
|
11
14
|
export declare function createObservableWithCustomAdministration<T>(value: T, Adm: new (obj: any) => Administration): T;
|
|
12
15
|
export declare function getObservable<T>(value: T): T;
|