r-state-tree 0.7.0 → 0.8.1

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
@@ -62,7 +62,7 @@ export default defineConfig({
62
62
 
63
63
  ## Core concepts
64
64
 
65
- - 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()`.
65
+ - Stores: application/view state containers. Create with `createStore()`, attach with `mount()`, and dispose with `Symbol.dispose`. Compose with `@child` and register owned `effect`/`reaction` behavior in constructors. Update reactive `props` via `updateStore()`.
66
66
  - 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`.
67
67
  - 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.
68
68
  - Reactivity: powered by signals. Use `observable()`, `computed` / `@computed`, `effect`, `reaction`, `batch`, and `untracked` for precise updates.
@@ -139,7 +139,7 @@ app.todo.title;
139
139
 
140
140
  - Always create with `createStore()` and attach with `mount()`. Stores cannot be constructed with `new` directly.
141
141
  - Prefer no custom constructor. Type stores as `Store<Props>` and access props via `this.props`.
142
- - If a constructor is necessary, call `super(props)` exactly once and keep it minimal; put effects in `storeDidMount`.
142
+ - Constructors may register framework-owned reactions and effects after `super(props)`. Store registrations activate when mounting and are disposed with the store; return cleanup from effects to release resources.
143
143
  - Do not shadow or re-declare `props` as a class field; `props` is read-only. Use the generic `Store<{ ... }>` for typing.
144
144
 
145
145
  ```ts
@@ -203,20 +203,27 @@ class ItemsStore extends Store {
203
203
  }
204
204
  ```
205
205
 
206
- ### Lifecycle hooks
206
+ ### Store lifetime and owned effects
207
207
 
208
- Stores support lifecycle methods:
208
+ Mounted stores expose reactive `isMounted` state and implement `Disposable`:
209
209
 
210
210
  ```ts
211
211
  class TodoStore extends Store {
212
- storeDidMount() {
213
- console.log("Store mounted");
214
- }
215
-
216
- storeWillUnmount() {
217
- console.log("Store will unmount");
212
+ constructor(props) {
213
+ super(props);
214
+ this.effect(() => {
215
+ const connection = connect();
216
+ return () => connection.close();
217
+ });
218
218
  }
219
219
  }
220
+
221
+ const store = mount(createStore(TodoStore));
222
+ store[Symbol.dispose]();
223
+
224
+ {
225
+ using store = mount(createStore(TodoStore));
226
+ } // Symbol.dispose is called automatically
220
227
  ```
221
228
 
222
229
  ### Reactions
@@ -225,15 +232,20 @@ Create side effects that run when reactive values change:
225
232
 
226
233
  ```ts
227
234
  class TodoStore extends Store {
228
- storeDidMount() {
235
+ constructor(props) {
236
+ super(props);
229
237
  this.reaction(
230
238
  () => this.props.title,
231
- (title) => console.log("Title changed:", title)
239
+ (title, previousTitle) => console.log(previousTitle, "->", title)
232
240
  );
233
241
  }
234
242
  }
235
243
  ```
236
244
 
245
+ `Store.effect()` and `Store.reaction()` register mount-scoped behavior. Mounting first links the complete store tree and marks it mounted bottom-up in one transaction. Registrations then activate bottom-up, so even a grandchild's initial effect observes every ancestor and descendant as mounted. A reaction establishes its initial value at activation and still skips its initial callback. Calling the disposer returned during construction cancels the pending registration; after mount, it disposes the live subscription. All registrations are disposed automatically with the store.
246
+
247
+ Use the exported standalone `effect()` or `reaction()` when observing a store from outside its owned lifetime—for example, to observe the public reactive `isMounted` transition to `false`.
248
+
237
249
  ### Context
238
250
 
239
251
  Share data across the store tree without prop drilling:
@@ -834,37 +846,55 @@ const off = onSnapshot(list, (snap) => {
834
846
 
835
847
  Mutate Models through domain methods and let snapshots record changes automatically.
836
848
 
837
- ### Model lifecycle hooks
849
+ ### Model creation and named factories
838
850
 
839
- Models support lifecycle methods:
851
+ `Model.create()` accepts a canonical snapshot. For defaults, identifier generation, normalization, migrations, or external API formats, use a named static factory that prepares the input before calling `create()`:
840
852
 
841
853
  ```ts
842
854
  class TodoModel extends Model {
843
- @child tags: TagModel[] = [];
855
+ @id id = "";
856
+ @state title = "";
844
857
 
845
- modelDidInit(snapshot?, ...args: unknown[]) {
846
- // Called when model is created via Model.create()
847
- // Receives the snapshot and any additional arguments passed to create()
848
- console.log("Model initialized", snapshot);
858
+ static new(title = "Untitled") {
859
+ return this.create({
860
+ id: crypto.randomUUID(),
861
+ title: title.trim(),
862
+ });
849
863
  }
850
864
 
851
- modelDidAttach() {
852
- // Called when this model is attached as a child to another model
853
- console.log("Model attached to parent");
865
+ static fromApi(input: ApiTodo) {
866
+ return this.create({
867
+ id: input.todo_id,
868
+ title: input.name.trim(),
869
+ });
854
870
  }
871
+ }
872
+
873
+ const todo = TodoModel.new(" Write documentation ");
874
+ todo.title; // "Write documentation"
875
+ ```
876
+
877
+ This keeps snapshot hydration predictable: `create(snapshot)` and `applySnapshot(model, snapshot)` both consume the same canonical shape. Named factories make non-canonical inputs and generated values explicit and independently typed. Nested snapshots must already be canonical.
878
+
879
+ ### Model attachment and disposal
855
880
 
856
- modelWillDetach() {
857
- // Called when this model is detached from its parent
858
- console.log("Model will be detached");
881
+ The public `parent` relationship is reactive. Register a model-owned reaction in the constructor to observe attachment and detachment:
882
+
883
+ ```ts
884
+ class TodoModel extends Model {
885
+ @child tags: TagModel[] = [];
886
+
887
+ constructor() {
888
+ super();
889
+ this.reaction(() => this.parent, (parent, previousParent) => {
890
+ if (previousParent) console.log("detached", previousParent);
891
+ if (parent) console.log("attached", parent);
892
+ });
859
893
  }
860
894
  }
861
895
  ```
862
896
 
863
- When to use each:
864
-
865
- - `modelDidInit`: initialize/normalize data based on the initial snapshot.
866
- - `modelDidAttach`: link to other models or read context after the model is part of a tree.
867
- - `modelWillDetach`: cleanup before the model is removed or replaced.
897
+ Detachment is reversible; model-owned effects survive reattachment. `model[Symbol.dispose]()` is terminal and recursively disposes owned child models, but never model refs.
868
898
 
869
899
  ### Model configuration
870
900
 
@@ -883,7 +913,7 @@ class User extends Model {
883
913
  class TodoModel extends Model {
884
914
  @id id = 0;
885
915
  @state title = "";
886
- @modelRef assignee?: User; // Reference to another model by ID
916
+ @modelRef(User) assignee?: User; // Reference to another model by ID
887
917
  @child metadata = MetadataModel.create(); // Nested child model
888
918
  @child tags: TagModel[] = []; // Array of child models
889
919
  }
@@ -928,14 +958,14 @@ class TodoModel extends Model {
928
958
 
929
959
  ### Model references
930
960
 
931
- Reference models by ID using `@modelRef` (or `static types`):
961
+ Reference models by ID using `@modelRef(ModelType)` (or `modelRef(ModelType)` in `static types`). The model constructor is required because identifiers are namespaced by model type:
932
962
 
933
963
  ```ts
934
964
  class ProjectModel extends Model {
935
965
  @id id = 0;
936
966
  @child users: User[] = [];
937
- @modelRef owner?: User; // Single reference
938
- @modelRef assignees: User[] = []; // Array of references
967
+ @modelRef(User) owner?: User; // Single reference
968
+ @modelRef(User) assignees: User[] = []; // Array of references
939
969
 
940
970
  assignOwner(userId: number) {
941
971
  // Find user by ID and set as owner
@@ -983,9 +1013,10 @@ class ContainerModel extends Model {
983
1013
  ## API surface
984
1014
 
985
1015
  - Stores
986
- - `Store`, `createStore`, `mount`, `unmount`, `updateStore`
1016
+ - `Store`, `createStore`, `mount`, `updateStore`
987
1017
  - Models
988
- - - `Model`, configuration: decorators (`@state`, `@id`, `@child`, `@modelRef`) or `static types` with `state`, `id`, `child`, `modelRef`
1018
+ - `Model`, `Model.create()`
1019
+ - configuration: decorators (`@state`, `@id`, `@child`, `@modelRef`) or `static types` with `state`, `id`, `child`, `modelRef`
989
1020
  - Store configuration
990
1021
  - decorators (`@child`, `@model`) or `static types` with `child`, `model`
991
1022
  - Snapshots
@@ -1049,12 +1080,21 @@ Use the observers/renderers provided by the signals bindings for your UI library
1049
1080
 
1050
1081
  ### Identifier and reference rules
1051
1082
 
1052
- - `@id` values are unique within a tree. They cannot be cleared to `undefined` after assignment.
1053
- - Identifiers can be reassigned to a new value (including in snapshots) as long as uniqueness is preserved.
1054
- - `@modelRef` requires the referenced model to have an id and be attached to the tree; the ref becomes `undefined` when the model detaches.
1083
+ - `@id` values are unique per exact runtime Model class within a tree. Different Model classes may use the same id; subclasses have their own identifier namespace. IDs cannot be cleared to `undefined` after assignment.
1084
+ - Identifiers are mutable and can be reassigned (including in snapshots) as long as the new id is unused by that exact Model class. A failed reassignment preserves the old id and its references.
1085
+ - Use `@modelRef(ModelType)` or `modelRef(ModelType)` when ids can overlap across Model classes; references resolve only within the declared Model class.
1086
+ - `@modelRef(ModelType)` requires an explicit model constructor. The referenced model must have an id and be attached to the tree; the ref becomes `undefined` when the model detaches.
1055
1087
  - When a model is re-attached to the same tree, compatible refs restore automatically; attaching to a different root does not restore prior refs.
1056
1088
  - `@modelRef` and `@child` can switch between single and array at runtime; reactions observe the property itself rather than internal array mutations.
1057
1089
 
1090
+ Use `findModelById` for an optional, typed lookup within a model tree:
1091
+
1092
+ ```ts
1093
+ const user = findModelById(root, User, 1); // User | undefined
1094
+ ```
1095
+
1096
+ The lookup is reactive inside an effect or computed value. It returns `undefined` when the matching model changes its id, detaches, or is disposed.
1097
+
1058
1098
  ### Snapshot diffs
1059
1099
 
1060
1100
  Use `onSnapshotDiff` to receive undo/redo payloads:
@@ -1167,17 +1207,23 @@ class ListModel extends Model {
1167
1207
  }
1168
1208
  ```
1169
1209
 
1170
- Lifecycle hooks:
1210
+ Lifecycle registration:
1171
1211
 
1172
1212
  ```ts
1173
1213
  class M extends Model {
1174
- modelDidInit() {}
1175
- modelDidAttach() {}
1176
- modelWillDetach() {}
1214
+ constructor() {
1215
+ super();
1216
+ this.reaction(() => this.parent, (parent, previousParent) => {});
1217
+ }
1177
1218
  }
1178
1219
  class S extends Store {
1179
- storeDidMount() {}
1180
- storeWillUnmount() {}
1220
+ constructor(props) {
1221
+ super(props);
1222
+ this.effect(() => {
1223
+ const resource = acquireResource();
1224
+ return () => resource.dispose();
1225
+ });
1226
+ }
1181
1227
  }
1182
1228
  ```
1183
1229
 
@@ -1199,11 +1245,11 @@ const off = onSnapshot(m, (snap) =>
1199
1245
  - 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`.
1200
1246
  - Passing observables to third‑party APIs that expect cloneable/serializable values (e.g. `structuredClone`). Use `source(value)` to get the backing value. It will be observable-free for values written via r-state-tree’s observable APIs (unwrap-on-write), but `source(...)` is **not** guaranteed observable-free if you manually seed observables into backing sources.
1201
1247
  - Creating child stores in constructors: `@child` must be on getters so identity and lifecycle can be managed by the framework.
1202
- - 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.
1248
+ - Passing `models` into child stores during mount can create a recursive mount loop. Break the ownership cycle rather than wiring models back through a child during mount.
1203
1249
 
1204
1250
  ### Circular store/model creation
1205
1251
 
1206
- 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.
1252
+ 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 guards this with a descriptive circular-creation error. Break the cycle so models are produced independently of the child mount.
1207
1253
 
1208
1254
  ## LLM implementation checklist
1209
1255
 
@@ -1222,7 +1268,7 @@ When child stores are created during mount with `models` that point back into th
1222
1268
  - For `createStore` props, extend `Record<string, unknown>` only if needed; otherwise rely on proper prop types and context.
1223
1269
  - Avoid barrel files if they cause import confusion; prefer direct imports.
1224
1270
  - Keep file boundaries clean: one model per file; avoid piling multiple models together.
1225
- - Do not shadow `props` or use constructors for work better suited to `storeDidMount`.
1271
+ - Do not shadow `props`; constructors are the registration phase for owned reactions and effects.
1226
1272
  - Use `@model`/`model` for injected models; `@child`/`child` for child stores; stable keys for arrays.
1227
1273
 
1228
1274
  ## Typing recipes
@@ -1236,10 +1282,10 @@ When child stores are created during mount with `models` that point back into th
1236
1282
  - Configuration (no decorators): `static types = { ... }` with `state`, `id`, `child`, `modelRef`, `model`, `computed`
1237
1283
  - Decorators (Models): `@state`, `@id`, `@child`, `@modelRef`
1238
1284
  - Decorators (Stores): `@child`, `@model`
1239
- - Core: `createStore`, `mount`, `unmount`, `updateStore`
1285
+ - Core: `createStore`, `mount`, `Symbol.dispose`, `updateStore`
1240
1286
  - Snapshots: `onSnapshot`, `toSnapshot`, `applySnapshot`, `onSnapshotDiff`
1241
- - Lifecycle: `storeDidMount`, `storeWillUnmount`, `modelDidInit`, `modelDidAttach`, `modelWillDetach`
1242
- - Best practices: domain in Models; delegate from Stores; stable keys for `@child`; in-place mutations in Models; no effectful constructors; don’t shadow `props`.
1287
+ - Lifecycle: reactive `Store.isMounted`, reactive `Model.parent`, and owned `reaction`/`effect`
1288
+ - Best practices: domain in Models; delegate from Stores; stable keys for `@child`; return cleanup from store effects; don’t shadow `props`.
1243
1289
 
1244
1290
  ## Testing
1245
1291
 
package/dist/api.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type { Snapshot, SnapshotDiff } from "./types";
1
+ import type { Snapshot, SnapshotDiff, IdType } from "./types";
2
2
  import type Store from "./store/Store";
3
3
  import type Model from "./model/Model";
4
+ export declare function findModelById<T extends Model>(root: Model, ModelType: new (...args: any[]) => T, id: IdType): T | undefined;
4
5
  export declare function mount<T extends Store>(container: T): T;
5
- export declare function unmount<S extends Store>(container: S): void;
6
6
  export declare function toSnapshot<T extends Model>(model: T): Snapshot<T>;
7
7
  export declare function onSnapshot<T extends Model>(model: T, callback: (snapshot: Snapshot<T>, model: T) => void): () => void;
8
8
  export declare function onSnapshotDiff<T extends Model>(model: T, callback: (snapshotDiff: SnapshotDiff<T>, model: T) => void): () => void;
@@ -1,6 +1,6 @@
1
1
  import "@tsmetadata/polyfill";
2
2
  export declare const child: any;
3
- export declare const modelRef: any;
3
+ export declare function modelRef<T extends Function>(childCtor: T): any;
4
4
  export declare const model: any;
5
5
  export declare const id: any;
6
6
  export declare const state: any;
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import "@tsmetadata/polyfill";
2
2
  import Store, { createStore, updateStore } from "./store/Store";
3
3
  import Model from "./model/Model";
4
- import { mount, unmount, onSnapshot, toSnapshot, applySnapshot, onSnapshotDiff } from "./api";
4
+ import { mount, onSnapshot, toSnapshot, applySnapshot, onSnapshotDiff, findModelById } from "./api";
5
5
  import { createContext } from "./context";
6
6
  import { toObservableTree } from "./toObservableTree";
7
- export { createStore, Store, Model, mount, unmount, updateStore, onSnapshot, onSnapshotDiff, toSnapshot, applySnapshot, createContext, toObservableTree, };
7
+ export { createStore, Store, Model, mount, updateStore, onSnapshot, onSnapshotDiff, toSnapshot, applySnapshot, findModelById, createContext, toObservableTree, };
8
8
  export { observable, computed, source, reportChanged, reportObserved, getSignal, isObservable, reaction, Signal, type ReadonlySignal, batch, untracked, effect, signal, Observable, } from "./observables";
9
9
  export * from "./decorators";
10
10
  export type { Configuration, Snapshot, SnapshotValue, IdType, SnapshotDiff, } from "./types";
@@ -1,17 +1,13 @@
1
1
  import type { ModelConfiguration, Snapshot } from "../types";
2
- type ExtractModelDidInitArgs<T extends Model> = T extends {
3
- modelDidInit(...args: infer Args): unknown;
4
- } ? Args extends [infer Snapshot, ...infer Rest] ? Rest : [] : [];
5
- export default class Model {
2
+ export default class Model implements Disposable {
6
3
  static types?: ModelConfiguration<unknown>;
7
4
  static childTypes: object;
8
5
  static create<T extends Model = Model>(this: {
9
6
  new (...args: unknown[]): T;
10
- }, snapshot?: Snapshot<T>, ...args: ExtractModelDidInitArgs<T>): T;
7
+ }, snapshot?: Snapshot<T>): T;
11
8
  constructor();
12
9
  get parent(): Model | null;
13
- modelDidInit(snapshot?: Snapshot<this>, ...args: unknown[]): void;
14
- modelDidAttach(): void;
15
- modelWillDetach(): void;
10
+ reaction<T>(track: () => T, callback: (value: T, previousValue: T) => void): () => void;
11
+ effect(callback: () => void | (() => void)): () => void;
12
+ [Symbol.dispose](): void;
16
13
  }
17
- export {};
@@ -20,10 +20,16 @@ export declare class ModelAdministration extends PreactObjectAdministration<any>
20
20
  private computedSnapshot;
21
21
  private snapshotMap;
22
22
  private contextCache;
23
+ private ownedDisposers;
24
+ private disposed;
23
25
  parentName: PropertyKey | null;
24
26
  get parent(): ModelAdministration | null;
25
27
  set parent(value: ModelAdministration | null);
26
28
  setConfiguration(configurationGetter: () => ModelConfiguration<any>): void;
29
+ assertUsable(): void;
30
+ private own;
31
+ reaction<T>(track: () => T, callback: (value: T, previousValue: T) => void): () => void;
32
+ effect(callback: () => void | (() => void)): () => void;
27
33
  private get configuration();
28
34
  private getReferencedAtom;
29
35
  private setState;
@@ -35,12 +41,17 @@ export declare class ModelAdministration extends PreactObjectAdministration<any>
35
41
  private getModelRefs;
36
42
  private setModelRef;
37
43
  private setModelRefs;
44
+ private getRequiredModelRefType;
45
+ private assertModelRefType;
38
46
  private attach;
47
+ private getReactiveRoot;
39
48
  private detach;
49
+ dispose(internal?: boolean): void;
40
50
  getContextValue<T>(contextId: symbol, provideSymbol: symbol, defaultValue: T | undefined, hasDefault: boolean): T;
41
51
  private lookupContextValue;
42
52
  private toJSON;
43
53
  onSnapshotChange(onChange: SnapshotChange<any>): () => void;
44
54
  loadSnapshot(snapshot: Snapshot<any>): void;
45
55
  getSnapshot(): Snapshot<any>;
56
+ getSnapshotForRollback(): Snapshot<any>;
46
57
  }
@@ -1,8 +1,8 @@
1
1
  import type { IdType } from "../types";
2
- import type Model from "./Model";
3
- export declare function onSnapshotLoad<T>(fn: () => T): T;
4
- export declare function setIdentifier(model: Model, id: string | number): void;
2
+ import Model from "./Model";
3
+ export type ModelConstructor<T extends Model = Model> = new (...args: any[]) => T;
4
+ export declare function setIdentifier(model: Model, id: IdType): void;
5
5
  export declare function getIdentifier(model: Model): IdType | undefined;
6
- export declare function getModelById(root: Model, id: IdType): Model | undefined;
6
+ export declare function getModelById<T extends Model>(root: Model, Type: ModelConstructor<T>, id: IdType): T | undefined;
7
7
  export declare function onModelAttached(model: Model): void;
8
8
  export declare function onModelDetached(model: Model): void;
@@ -41,7 +41,7 @@ export declare function createSignal<T>(initialValue: T): SignalNode<T>;
41
41
  export declare function createAtom(): AtomNode;
42
42
  export { effect, batch, untracked, Signal };
43
43
  export type { ReadonlySignal };
44
- export declare function reaction<T>(fn: () => T, callback: (value: T) => void): () => void;
44
+ export declare function reaction<T>(fn: () => T, callback: (value: T, previousValue: T) => void): () => void;
45
45
  export type Listener = {
46
46
  dispose: () => void;
47
47
  track: <T>(trackFn: () => T) => T;