@pyreon/state-tree 0.11.2 → 0.11.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/state-tree",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Structured reactive state tree — composable models with snapshots, patches, and middleware",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,10 +46,10 @@
46
46
  "lint": "biome check ."
47
47
  },
48
48
  "peerDependencies": {
49
- "@pyreon/reactivity": "^0.11.2"
49
+ "@pyreon/reactivity": "^0.11.4"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@happy-dom/global-registrator": "^20.8.3",
53
- "@pyreon/reactivity": "^0.11.2"
53
+ "@pyreon/reactivity": "^0.11.4"
54
54
  }
55
55
  }
@@ -0,0 +1,485 @@
1
+ import { computed, effect } from "@pyreon/reactivity"
2
+ import type { Patch } from "../index"
3
+ import {
4
+ addMiddleware,
5
+ applyPatch,
6
+ applySnapshot,
7
+ getSnapshot,
8
+ model,
9
+ onPatch,
10
+ resetAllHooks,
11
+ resetHook,
12
+ } from "../index"
13
+
14
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
15
+
16
+ const Counter = model({
17
+ state: { count: 0 },
18
+ views: (self) => ({
19
+ doubled: computed(() => self.count() * 2),
20
+ }),
21
+ actions: (self) => ({
22
+ inc: () => self.count.update((c: number) => c + 1),
23
+ dec: () => self.count.update((c: number) => c - 1),
24
+ add: (n: number) => self.count.update((c: number) => c + n),
25
+ reset: () => self.count.set(0),
26
+ }),
27
+ })
28
+
29
+ const Profile = model({
30
+ state: { name: "", bio: "" },
31
+ actions: (self) => ({
32
+ rename: (n: string) => self.name.set(n),
33
+ setBio: (b: string) => self.bio.set(b),
34
+ }),
35
+ })
36
+
37
+ const App = model({
38
+ state: { profile: Profile, title: "My App" },
39
+ actions: (self) => ({
40
+ setTitle: (t: string) => self.title.set(t),
41
+ }),
42
+ })
43
+
44
+ // ─── getSnapshot — JSON-serializable output ────────────────────────────────
45
+
46
+ describe("getSnapshot — JSON-serializable output", () => {
47
+ it("returns a plain object that can be JSON.stringify'd and parsed back", () => {
48
+ const c = Counter.create({ count: 42 })
49
+ const snap = getSnapshot(c)
50
+ const json = JSON.stringify(snap)
51
+ const parsed = JSON.parse(json)
52
+ expect(parsed).toEqual({ count: 42 })
53
+ })
54
+
55
+ it("snapshot contains no signal functions", () => {
56
+ const c = Counter.create({ count: 5 })
57
+ const snap = getSnapshot(c)
58
+ for (const val of Object.values(snap)) {
59
+ expect(typeof val).not.toBe("function")
60
+ }
61
+ })
62
+
63
+ it("nested model snapshot is fully serializable", () => {
64
+ const app = App.create({
65
+ profile: { name: "Alice", bio: "dev" },
66
+ title: "Test",
67
+ })
68
+ const snap = getSnapshot(app)
69
+ const json = JSON.stringify(snap)
70
+ const parsed = JSON.parse(json)
71
+ expect(parsed).toEqual({
72
+ profile: { name: "Alice", bio: "dev" },
73
+ title: "Test",
74
+ })
75
+ })
76
+
77
+ it("snapshot does not include views or actions", () => {
78
+ const c = Counter.create({ count: 3 })
79
+ const snap = getSnapshot(c)
80
+ expect(snap).toEqual({ count: 3 })
81
+ expect(snap).not.toHaveProperty("doubled")
82
+ expect(snap).not.toHaveProperty("inc")
83
+ })
84
+ })
85
+
86
+ // ─── applySnapshot — restores model state ──────────────────────────────────
87
+
88
+ describe("applySnapshot — restores model state", () => {
89
+ it("restores a complete snapshot", () => {
90
+ const c = Counter.create({ count: 99 })
91
+ applySnapshot(c, { count: 0 })
92
+ expect(c.count()).toBe(0)
93
+ })
94
+
95
+ it("restores partial snapshot — only specified keys", () => {
96
+ const M = model({ state: { a: 1, b: 2, c: 3 } })
97
+ const m = M.create({ a: 10, b: 20, c: 30 })
98
+ applySnapshot(m, { b: 99 })
99
+ expect(m.a()).toBe(10)
100
+ expect(m.b()).toBe(99)
101
+ expect(m.c()).toBe(30)
102
+ })
103
+
104
+ it("restores nested model state recursively", () => {
105
+ const app = App.create({
106
+ profile: { name: "Alice", bio: "dev" },
107
+ title: "Old",
108
+ })
109
+ applySnapshot(app, {
110
+ profile: { name: "Bob", bio: "engineer" },
111
+ title: "New",
112
+ })
113
+ expect(app.profile().name()).toBe("Bob")
114
+ expect(app.profile().bio()).toBe("engineer")
115
+ expect(app.title()).toBe("New")
116
+ })
117
+
118
+ it("roundtrips: getSnapshot -> applySnapshot produces same state", () => {
119
+ const app = App.create({
120
+ profile: { name: "Carol", bio: "designer" },
121
+ title: "Portfolio",
122
+ })
123
+ const snap = getSnapshot(app)
124
+
125
+ const app2 = App.create()
126
+ applySnapshot(app2, snap)
127
+ expect(getSnapshot(app2)).toEqual(snap)
128
+ })
129
+
130
+ it("batches updates — effect fires once for multi-field snapshot", () => {
131
+ const M = model({ state: { x: 0, y: 0, z: 0 } })
132
+ const m = M.create()
133
+ let effectRuns = 0
134
+ effect(() => {
135
+ m.x()
136
+ m.y()
137
+ m.z()
138
+ effectRuns++
139
+ })
140
+ effectRuns = 0
141
+ applySnapshot(m, { x: 1, y: 2, z: 3 })
142
+ expect(effectRuns).toBe(1)
143
+ })
144
+ })
145
+
146
+ // ─── onPatch — listener receives correct format ────────────────────────────
147
+
148
+ describe("onPatch — patch format", () => {
149
+ it("patch has op, path, and value fields", () => {
150
+ const c = Counter.create()
151
+ const patches: Patch[] = []
152
+ onPatch(c, (p) => patches.push(p))
153
+ c.inc()
154
+ expect(patches).toHaveLength(1)
155
+ expect(patches[0]).toHaveProperty("op", "replace")
156
+ expect(patches[0]).toHaveProperty("path", "/count")
157
+ expect(patches[0]).toHaveProperty("value", 1)
158
+ })
159
+
160
+ it("path uses JSON pointer format with leading slash", () => {
161
+ const c = Counter.create()
162
+ const patches: Patch[] = []
163
+ onPatch(c, (p) => patches.push(p))
164
+ c.add(5)
165
+ expect(patches[0]!.path).toMatch(/^\//)
166
+ })
167
+
168
+ it("nested model patches have composite paths", () => {
169
+ const app = App.create({ profile: { name: "A", bio: "" }, title: "" })
170
+ const patches: Patch[] = []
171
+ onPatch(app, (p) => patches.push(p))
172
+
173
+ app.profile().rename("B")
174
+ expect(patches[0]!.path).toBe("/profile/name")
175
+ })
176
+
177
+ it("value contains new value after mutation, not old", () => {
178
+ const c = Counter.create({ count: 10 })
179
+ const patches: Patch[] = []
180
+ onPatch(c, (p) => patches.push(p))
181
+
182
+ c.add(5)
183
+ expect(patches[0]!.value).toBe(15)
184
+ })
185
+
186
+ it("emits patches for each signal write in sequence", () => {
187
+ const c = Counter.create()
188
+ const patches: Patch[] = []
189
+ onPatch(c, (p) => patches.push(p))
190
+
191
+ c.inc()
192
+ c.inc()
193
+ c.add(10)
194
+
195
+ expect(patches).toHaveLength(3)
196
+ expect(patches.map((p) => p.value)).toEqual([1, 2, 12])
197
+ })
198
+ })
199
+
200
+ // ─── applyPatch — applies patches correctly ────────────────────────────────
201
+
202
+ describe("applyPatch — applies patches", () => {
203
+ it("applies a single replace patch to top-level field", () => {
204
+ const c = Counter.create()
205
+ applyPatch(c, { op: "replace", path: "/count", value: 42 })
206
+ expect(c.count()).toBe(42)
207
+ })
208
+
209
+ it("applies array of patches in order", () => {
210
+ const c = Counter.create()
211
+ applyPatch(c, [
212
+ { op: "replace", path: "/count", value: 5 },
213
+ { op: "replace", path: "/count", value: 10 },
214
+ ])
215
+ expect(c.count()).toBe(10)
216
+ })
217
+
218
+ it("applies patches to nested model instances", () => {
219
+ const app = App.create({
220
+ profile: { name: "A", bio: "b" },
221
+ title: "t",
222
+ })
223
+ applyPatch(app, { op: "replace", path: "/profile/name", value: "B" })
224
+ expect(app.profile().name()).toBe("B")
225
+ })
226
+
227
+ it("roundtrip: record patches with onPatch, replay on fresh instance", () => {
228
+ const original = Counter.create()
229
+ const patches: Patch[] = []
230
+ onPatch(original, (p) => patches.push({ ...p }))
231
+
232
+ original.inc()
233
+ original.add(10)
234
+ original.dec()
235
+
236
+ const replica = Counter.create()
237
+ applyPatch(replica, patches)
238
+ expect(replica.count()).toBe(original.count())
239
+ expect(getSnapshot(replica)).toEqual(getSnapshot(original))
240
+ })
241
+
242
+ it("throws for unsupported op", () => {
243
+ const c = Counter.create()
244
+ expect(() => applyPatch(c, { op: "add" as any, path: "/count", value: 1 })).toThrow(
245
+ "unsupported op",
246
+ )
247
+ })
248
+
249
+ it("throws for empty path", () => {
250
+ const c = Counter.create()
251
+ expect(() => applyPatch(c, { op: "replace", path: "", value: 1 })).toThrow("empty path")
252
+ })
253
+
254
+ it("throws for unknown key", () => {
255
+ const c = Counter.create()
256
+ expect(() => applyPatch(c, { op: "replace", path: "/unknown", value: 1 })).toThrow(
257
+ "unknown state key",
258
+ )
259
+ })
260
+
261
+ it("throws for non-model instance", () => {
262
+ expect(() => applyPatch({}, { op: "replace", path: "/x", value: 1 })).toThrow(
263
+ "not a model instance",
264
+ )
265
+ })
266
+ })
267
+
268
+ // ─── addMiddleware — intercepts actions ────────────────────────────────────
269
+
270
+ describe("addMiddleware — intercepts actions", () => {
271
+ it("captures action name and args", () => {
272
+ const c = Counter.create()
273
+ const calls: { name: string; args: unknown[] }[] = []
274
+ addMiddleware(c, (call, next) => {
275
+ calls.push({ name: call.name, args: [...call.args] })
276
+ return next(call)
277
+ })
278
+ c.add(5)
279
+ expect(calls).toEqual([{ name: "add", args: [5] }])
280
+ })
281
+
282
+ it("middleware can block action by not calling next", () => {
283
+ const c = Counter.create()
284
+ addMiddleware(c, () => {
285
+ /* intentionally block */
286
+ })
287
+ c.inc()
288
+ expect(c.count()).toBe(0)
289
+ })
290
+
291
+ it("middleware can modify args", () => {
292
+ const c = Counter.create()
293
+ addMiddleware(c, (call, next) => {
294
+ if (call.name === "add") {
295
+ return next({ ...call, args: [(call.args[0] as number) * 3] })
296
+ }
297
+ return next(call)
298
+ })
299
+ c.add(5)
300
+ expect(c.count()).toBe(15) // 5 * 3
301
+ })
302
+
303
+ it("unsub removes the middleware", () => {
304
+ const c = Counter.create()
305
+ const log: string[] = []
306
+ const unsub = addMiddleware(c, (call, next) => {
307
+ log.push(call.name)
308
+ return next(call)
309
+ })
310
+ c.inc()
311
+ expect(log).toHaveLength(1)
312
+
313
+ unsub()
314
+ c.inc()
315
+ expect(log).toHaveLength(1) // no new entries
316
+ })
317
+
318
+ it("multiple middlewares execute in Koa-style onion order", () => {
319
+ const c = Counter.create()
320
+ const log: string[] = []
321
+ addMiddleware(c, (call, next) => {
322
+ log.push("A:before")
323
+ const r = next(call)
324
+ log.push("A:after")
325
+ return r
326
+ })
327
+ addMiddleware(c, (call, next) => {
328
+ log.push("B:before")
329
+ const r = next(call)
330
+ log.push("B:after")
331
+ return r
332
+ })
333
+ c.inc()
334
+ expect(log).toEqual(["A:before", "B:before", "B:after", "A:after"])
335
+ })
336
+ })
337
+
338
+ // ─── Nested model composition ──────────────────────────────────────────────
339
+
340
+ describe("nested model composition", () => {
341
+ it("deeply nested models work correctly", () => {
342
+ const Leaf = model({
343
+ state: { val: 0 },
344
+ actions: (self) => ({
345
+ set: (v: number) => self.val.set(v),
346
+ }),
347
+ })
348
+ const Branch = model({
349
+ state: { leaf: Leaf, tag: "" },
350
+ actions: (self) => ({
351
+ setTag: (t: string) => self.tag.set(t),
352
+ }),
353
+ })
354
+ const Root = model({
355
+ state: { branch: Branch, name: "root" },
356
+ })
357
+
358
+ const root = Root.create({
359
+ branch: { leaf: { val: 42 }, tag: "test" },
360
+ name: "myRoot",
361
+ })
362
+
363
+ expect(root.branch().leaf().val()).toBe(42)
364
+ expect(root.branch().tag()).toBe("test")
365
+ expect(root.name()).toBe("myRoot")
366
+ })
367
+
368
+ it("nested model patches propagate up with correct paths", () => {
369
+ const Leaf = model({
370
+ state: { val: 0 },
371
+ actions: (self) => ({
372
+ setVal: (v: number) => self.val.set(v),
373
+ }),
374
+ })
375
+ const Branch = model({
376
+ state: { leaf: Leaf },
377
+ })
378
+ const Root = model({
379
+ state: { branch: Branch },
380
+ })
381
+
382
+ const root = Root.create()
383
+ const patches: Patch[] = []
384
+ onPatch(root, (p) => patches.push(p))
385
+
386
+ root.branch().leaf().setVal(99)
387
+ expect(patches).toHaveLength(1)
388
+ expect(patches[0]!.path).toBe("/branch/leaf/val")
389
+ expect(patches[0]!.value).toBe(99)
390
+ })
391
+
392
+ it("nested getSnapshot serializes all levels", () => {
393
+ const Leaf = model({ state: { x: 1 } })
394
+ const Mid = model({ state: { leaf: Leaf, y: 2 } })
395
+ const Top = model({ state: { mid: Mid, z: 3 } })
396
+
397
+ const top = Top.create()
398
+ expect(getSnapshot(top)).toEqual({
399
+ mid: { leaf: { x: 1 }, y: 2 },
400
+ z: 3,
401
+ })
402
+ })
403
+
404
+ it("applyPatch to deeply nested path works", () => {
405
+ const Leaf = model({ state: { x: 0 } })
406
+ const Mid = model({ state: { leaf: Leaf } })
407
+ const Top = model({ state: { mid: Mid } })
408
+
409
+ const top = Top.create()
410
+ applyPatch(top, { op: "replace", path: "/mid/leaf/x", value: 999 })
411
+ expect(top.mid().leaf().x()).toBe(999)
412
+ })
413
+ })
414
+
415
+ // ─── asHook — singleton hook ───────────────────────────────────────────────
416
+
417
+ describe("asHook — creates singleton hook", () => {
418
+ afterEach(() => resetAllHooks())
419
+
420
+ it("returns the same instance every time", () => {
421
+ const useC = Counter.asHook("hook-same")
422
+ const a = useC()
423
+ const b = useC()
424
+ expect(a).toBe(b)
425
+ })
426
+
427
+ it("state mutations persist across calls", () => {
428
+ const useC = Counter.asHook("hook-persist")
429
+ useC().add(10)
430
+ expect(useC().count()).toBe(10)
431
+ })
432
+
433
+ it("different ids yield independent instances", () => {
434
+ const useA = Counter.asHook("hook-id-a")
435
+ const useB = Counter.asHook("hook-id-b")
436
+ useA().add(5)
437
+ expect(useA().count()).toBe(5)
438
+ expect(useB().count()).toBe(0)
439
+ })
440
+
441
+ it("resetHook clears specific singleton", () => {
442
+ const useC = Counter.asHook("hook-reset-2")
443
+ useC().add(100)
444
+ resetHook("hook-reset-2")
445
+ expect(useC().count()).toBe(0)
446
+ })
447
+
448
+ it("resetAllHooks clears all singletons", () => {
449
+ const useA = Counter.asHook("hook-all-1")
450
+ const useB = Counter.asHook("hook-all-2")
451
+ useA().add(5)
452
+ useB().add(10)
453
+
454
+ resetAllHooks()
455
+
456
+ expect(useA().count()).toBe(0)
457
+ expect(useB().count()).toBe(0)
458
+ })
459
+ })
460
+
461
+ // ─── Effect reactivity ─────────────────────────────────────────────────────
462
+
463
+ describe("effect reactivity with model instances", () => {
464
+ it("effect tracks signal reads from model instance", () => {
465
+ const c = Counter.create()
466
+ const observed: number[] = []
467
+ effect(() => {
468
+ observed.push(c.count())
469
+ })
470
+ c.inc()
471
+ c.inc()
472
+ expect(observed).toEqual([0, 1, 2])
473
+ })
474
+
475
+ it("effect tracks computed views", () => {
476
+ const c = Counter.create({ count: 1 })
477
+ const observed: number[] = []
478
+ effect(() => {
479
+ observed.push(c.doubled())
480
+ })
481
+ c.inc()
482
+ c.add(5)
483
+ expect(observed).toEqual([2, 4, 14])
484
+ })
485
+ })