@pyreon/state-tree 0.11.4 → 0.11.6
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 +63 -63
- package/lib/devtools.js.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +1 -1
- package/package.json +14 -14
- package/src/devtools.ts +1 -1
- package/src/index.ts +6 -6
- package/src/instance.ts +8 -8
- package/src/middleware.ts +3 -3
- package/src/model.ts +4 -4
- package/src/patch.ts +14 -14
- package/src/registry.ts +2 -2
- package/src/snapshot.ts +6 -6
- package/src/tests/comprehensive.test.ts +109 -109
- package/src/tests/devtools.test.ts +43 -43
- package/src/tests/edge-cases.test.ts +138 -138
- package/src/tests/model.test.ts +157 -157
- package/src/types.ts +3 -3
package/src/patch.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { Signal } from
|
|
2
|
-
import { batch } from
|
|
3
|
-
import { instanceMeta, isModelInstance } from
|
|
4
|
-
import type { Patch, PatchListener } from
|
|
1
|
+
import type { Signal } from '@pyreon/reactivity'
|
|
2
|
+
import { batch } from '@pyreon/reactivity'
|
|
3
|
+
import { instanceMeta, isModelInstance } from './registry'
|
|
4
|
+
import type { Patch, PatchListener } from './types'
|
|
5
5
|
|
|
6
6
|
/** Property names that must never be used as patch path segments. */
|
|
7
|
-
const RESERVED_KEYS = new Set([
|
|
7
|
+
const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
8
8
|
|
|
9
9
|
// ─── Tracked signal ───────────────────────────────────────────────────────────
|
|
10
10
|
|
|
@@ -36,7 +36,7 @@ export function trackedSignal<T>(
|
|
|
36
36
|
// For model instances, emit the snapshot rather than the live object
|
|
37
37
|
// so patches are always plain JSON-serializable values.
|
|
38
38
|
const patchValue = isModelInstance(newValue) ? snapshotValue(newValue as object) : newValue
|
|
39
|
-
emitPatch({ op:
|
|
39
|
+
emitPatch({ op: 'replace', path, value: patchValue })
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -76,7 +76,7 @@ function snapshotValue(instance: object): Record<string, unknown> {
|
|
|
76
76
|
*/
|
|
77
77
|
export function onPatch(instance: object, listener: PatchListener): () => void {
|
|
78
78
|
const meta = instanceMeta.get(instance)
|
|
79
|
-
if (!meta) throw new Error(
|
|
79
|
+
if (!meta) throw new Error('[@pyreon/state-tree] onPatch: not a model instance')
|
|
80
80
|
meta.patchListeners.add(listener)
|
|
81
81
|
return () => meta.patchListeners.delete(listener)
|
|
82
82
|
}
|
|
@@ -105,13 +105,13 @@ export function applyPatch(instance: object, patch: Patch | Patch[]): void {
|
|
|
105
105
|
|
|
106
106
|
batch(() => {
|
|
107
107
|
for (const p of patches) {
|
|
108
|
-
if (p.op !==
|
|
108
|
+
if (p.op !== 'replace') {
|
|
109
109
|
throw new Error(`[@pyreon/state-tree] applyPatch: unsupported op "${p.op}"`)
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
const segments = p.path.split(
|
|
112
|
+
const segments = p.path.split('/').filter(Boolean)
|
|
113
113
|
if (segments.length === 0) {
|
|
114
|
-
throw new Error(
|
|
114
|
+
throw new Error('[@pyreon/state-tree] applyPatch: empty path')
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
// Walk to the target instance for nested paths
|
|
@@ -125,11 +125,11 @@ export function applyPatch(instance: object, patch: Patch | Patch[]): void {
|
|
|
125
125
|
if (!meta)
|
|
126
126
|
throw new Error(`[@pyreon/state-tree] applyPatch: not a model instance at "${segment}"`)
|
|
127
127
|
const sig = (target as Record<string, Signal<unknown>>)[segment]
|
|
128
|
-
if (!sig || typeof sig.peek !==
|
|
128
|
+
if (!sig || typeof sig.peek !== 'function') {
|
|
129
129
|
throw new Error(`[@pyreon/state-tree] applyPatch: unknown state key "${segment}"`)
|
|
130
130
|
}
|
|
131
131
|
const nested = sig.peek()
|
|
132
|
-
if (!nested || typeof nested !==
|
|
132
|
+
if (!nested || typeof nested !== 'object' || !isModelInstance(nested)) {
|
|
133
133
|
throw new Error(
|
|
134
134
|
`[@pyreon/state-tree] applyPatch: "${segment}" is not a nested model instance`,
|
|
135
135
|
)
|
|
@@ -142,13 +142,13 @@ export function applyPatch(instance: object, patch: Patch | Patch[]): void {
|
|
|
142
142
|
throw new Error(`[@pyreon/state-tree] applyPatch: reserved property name "${lastKey}"`)
|
|
143
143
|
}
|
|
144
144
|
const meta = instanceMeta.get(target)
|
|
145
|
-
if (!meta) throw new Error(
|
|
145
|
+
if (!meta) throw new Error('[@pyreon/state-tree] applyPatch: not a model instance')
|
|
146
146
|
if (!meta.stateKeys.includes(lastKey)) {
|
|
147
147
|
throw new Error(`[@pyreon/state-tree] applyPatch: unknown state key "${lastKey}"`)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
const sig = (target as Record<string, Signal<unknown>>)[lastKey]
|
|
151
|
-
if (sig && typeof sig.set ===
|
|
151
|
+
if (sig && typeof sig.set === 'function') {
|
|
152
152
|
sig.set(p.value)
|
|
153
153
|
}
|
|
154
154
|
}
|
package/src/registry.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InstanceMeta } from
|
|
1
|
+
import type { InstanceMeta } from './types'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* WeakMap from every model instance object → its internal metadata.
|
|
@@ -8,5 +8,5 @@ export const instanceMeta = new WeakMap<object, InstanceMeta>()
|
|
|
8
8
|
|
|
9
9
|
/** Returns true when a value is a model instance (has metadata registered). */
|
|
10
10
|
export function isModelInstance(value: unknown): boolean {
|
|
11
|
-
return value != null && typeof value ===
|
|
11
|
+
return value != null && typeof value === 'object' && instanceMeta.has(value as object)
|
|
12
12
|
}
|
package/src/snapshot.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { Signal } from
|
|
2
|
-
import { batch } from
|
|
3
|
-
import { instanceMeta, isModelInstance } from
|
|
4
|
-
import type { Snapshot, StateShape } from
|
|
1
|
+
import type { Signal } from '@pyreon/reactivity'
|
|
2
|
+
import { batch } from '@pyreon/reactivity'
|
|
3
|
+
import { instanceMeta, isModelInstance } from './registry'
|
|
4
|
+
import type { Snapshot, StateShape } from './types'
|
|
5
5
|
|
|
6
6
|
// ─── getSnapshot ──────────────────────────────────────────────────────────────
|
|
7
7
|
|
|
@@ -15,7 +15,7 @@ import type { Snapshot, StateShape } from "./types"
|
|
|
15
15
|
*/
|
|
16
16
|
export function getSnapshot<TState extends StateShape>(instance: object): Snapshot<TState> {
|
|
17
17
|
const meta = instanceMeta.get(instance)
|
|
18
|
-
if (!meta) throw new Error(
|
|
18
|
+
if (!meta) throw new Error('[@pyreon/state-tree] getSnapshot: not a model instance')
|
|
19
19
|
|
|
20
20
|
const out: Record<string, unknown> = {}
|
|
21
21
|
for (const key of meta.stateKeys) {
|
|
@@ -42,7 +42,7 @@ export function applySnapshot<TState extends StateShape>(
|
|
|
42
42
|
snapshot: Partial<Snapshot<TState>>,
|
|
43
43
|
): void {
|
|
44
44
|
const meta = instanceMeta.get(instance)
|
|
45
|
-
if (!meta) throw new Error(
|
|
45
|
+
if (!meta) throw new Error('[@pyreon/state-tree] applySnapshot: not a model instance')
|
|
46
46
|
|
|
47
47
|
batch(() => {
|
|
48
48
|
for (const key of meta.stateKeys) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { computed, effect } from
|
|
2
|
-
import type { Patch } from
|
|
1
|
+
import { computed, effect } from '@pyreon/reactivity'
|
|
2
|
+
import type { Patch } from '../index'
|
|
3
3
|
import {
|
|
4
4
|
addMiddleware,
|
|
5
5
|
applyPatch,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
onPatch,
|
|
10
10
|
resetAllHooks,
|
|
11
11
|
resetHook,
|
|
12
|
-
} from
|
|
12
|
+
} from '../index'
|
|
13
13
|
|
|
14
14
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
15
15
|
|
|
@@ -27,7 +27,7 @@ const Counter = model({
|
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
const Profile = model({
|
|
30
|
-
state: { name:
|
|
30
|
+
state: { name: '', bio: '' },
|
|
31
31
|
actions: (self) => ({
|
|
32
32
|
rename: (n: string) => self.name.set(n),
|
|
33
33
|
setBio: (b: string) => self.bio.set(b),
|
|
@@ -35,7 +35,7 @@ const Profile = model({
|
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
const App = model({
|
|
38
|
-
state: { profile: Profile, title:
|
|
38
|
+
state: { profile: Profile, title: 'My App' },
|
|
39
39
|
actions: (self) => ({
|
|
40
40
|
setTitle: (t: string) => self.title.set(t),
|
|
41
41
|
}),
|
|
@@ -43,7 +43,7 @@ const App = model({
|
|
|
43
43
|
|
|
44
44
|
// ─── getSnapshot — JSON-serializable output ────────────────────────────────
|
|
45
45
|
|
|
46
|
-
describe(
|
|
46
|
+
describe('getSnapshot — JSON-serializable output', () => {
|
|
47
47
|
it("returns a plain object that can be JSON.stringify'd and parsed back", () => {
|
|
48
48
|
const c = Counter.create({ count: 42 })
|
|
49
49
|
const snap = getSnapshot(c)
|
|
@@ -52,47 +52,47 @@ describe("getSnapshot — JSON-serializable output", () => {
|
|
|
52
52
|
expect(parsed).toEqual({ count: 42 })
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
-
it(
|
|
55
|
+
it('snapshot contains no signal functions', () => {
|
|
56
56
|
const c = Counter.create({ count: 5 })
|
|
57
57
|
const snap = getSnapshot(c)
|
|
58
58
|
for (const val of Object.values(snap)) {
|
|
59
|
-
expect(typeof val).not.toBe(
|
|
59
|
+
expect(typeof val).not.toBe('function')
|
|
60
60
|
}
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
it(
|
|
63
|
+
it('nested model snapshot is fully serializable', () => {
|
|
64
64
|
const app = App.create({
|
|
65
|
-
profile: { name:
|
|
66
|
-
title:
|
|
65
|
+
profile: { name: 'Alice', bio: 'dev' },
|
|
66
|
+
title: 'Test',
|
|
67
67
|
})
|
|
68
68
|
const snap = getSnapshot(app)
|
|
69
69
|
const json = JSON.stringify(snap)
|
|
70
70
|
const parsed = JSON.parse(json)
|
|
71
71
|
expect(parsed).toEqual({
|
|
72
|
-
profile: { name:
|
|
73
|
-
title:
|
|
72
|
+
profile: { name: 'Alice', bio: 'dev' },
|
|
73
|
+
title: 'Test',
|
|
74
74
|
})
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
-
it(
|
|
77
|
+
it('snapshot does not include views or actions', () => {
|
|
78
78
|
const c = Counter.create({ count: 3 })
|
|
79
79
|
const snap = getSnapshot(c)
|
|
80
80
|
expect(snap).toEqual({ count: 3 })
|
|
81
|
-
expect(snap).not.toHaveProperty(
|
|
82
|
-
expect(snap).not.toHaveProperty(
|
|
81
|
+
expect(snap).not.toHaveProperty('doubled')
|
|
82
|
+
expect(snap).not.toHaveProperty('inc')
|
|
83
83
|
})
|
|
84
84
|
})
|
|
85
85
|
|
|
86
86
|
// ─── applySnapshot — restores model state ──────────────────────────────────
|
|
87
87
|
|
|
88
|
-
describe(
|
|
89
|
-
it(
|
|
88
|
+
describe('applySnapshot — restores model state', () => {
|
|
89
|
+
it('restores a complete snapshot', () => {
|
|
90
90
|
const c = Counter.create({ count: 99 })
|
|
91
91
|
applySnapshot(c, { count: 0 })
|
|
92
92
|
expect(c.count()).toBe(0)
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
it(
|
|
95
|
+
it('restores partial snapshot — only specified keys', () => {
|
|
96
96
|
const M = model({ state: { a: 1, b: 2, c: 3 } })
|
|
97
97
|
const m = M.create({ a: 10, b: 20, c: 30 })
|
|
98
98
|
applySnapshot(m, { b: 99 })
|
|
@@ -101,24 +101,24 @@ describe("applySnapshot — restores model state", () => {
|
|
|
101
101
|
expect(m.c()).toBe(30)
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
-
it(
|
|
104
|
+
it('restores nested model state recursively', () => {
|
|
105
105
|
const app = App.create({
|
|
106
|
-
profile: { name:
|
|
107
|
-
title:
|
|
106
|
+
profile: { name: 'Alice', bio: 'dev' },
|
|
107
|
+
title: 'Old',
|
|
108
108
|
})
|
|
109
109
|
applySnapshot(app, {
|
|
110
|
-
profile: { name:
|
|
111
|
-
title:
|
|
110
|
+
profile: { name: 'Bob', bio: 'engineer' },
|
|
111
|
+
title: 'New',
|
|
112
112
|
})
|
|
113
|
-
expect(app.profile().name()).toBe(
|
|
114
|
-
expect(app.profile().bio()).toBe(
|
|
115
|
-
expect(app.title()).toBe(
|
|
113
|
+
expect(app.profile().name()).toBe('Bob')
|
|
114
|
+
expect(app.profile().bio()).toBe('engineer')
|
|
115
|
+
expect(app.title()).toBe('New')
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
-
it(
|
|
118
|
+
it('roundtrips: getSnapshot -> applySnapshot produces same state', () => {
|
|
119
119
|
const app = App.create({
|
|
120
|
-
profile: { name:
|
|
121
|
-
title:
|
|
120
|
+
profile: { name: 'Carol', bio: 'designer' },
|
|
121
|
+
title: 'Portfolio',
|
|
122
122
|
})
|
|
123
123
|
const snap = getSnapshot(app)
|
|
124
124
|
|
|
@@ -127,7 +127,7 @@ describe("applySnapshot — restores model state", () => {
|
|
|
127
127
|
expect(getSnapshot(app2)).toEqual(snap)
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
-
it(
|
|
130
|
+
it('batches updates — effect fires once for multi-field snapshot', () => {
|
|
131
131
|
const M = model({ state: { x: 0, y: 0, z: 0 } })
|
|
132
132
|
const m = M.create()
|
|
133
133
|
let effectRuns = 0
|
|
@@ -145,19 +145,19 @@ describe("applySnapshot — restores model state", () => {
|
|
|
145
145
|
|
|
146
146
|
// ─── onPatch — listener receives correct format ────────────────────────────
|
|
147
147
|
|
|
148
|
-
describe(
|
|
149
|
-
it(
|
|
148
|
+
describe('onPatch — patch format', () => {
|
|
149
|
+
it('patch has op, path, and value fields', () => {
|
|
150
150
|
const c = Counter.create()
|
|
151
151
|
const patches: Patch[] = []
|
|
152
152
|
onPatch(c, (p) => patches.push(p))
|
|
153
153
|
c.inc()
|
|
154
154
|
expect(patches).toHaveLength(1)
|
|
155
|
-
expect(patches[0]).toHaveProperty(
|
|
156
|
-
expect(patches[0]).toHaveProperty(
|
|
157
|
-
expect(patches[0]).toHaveProperty(
|
|
155
|
+
expect(patches[0]).toHaveProperty('op', 'replace')
|
|
156
|
+
expect(patches[0]).toHaveProperty('path', '/count')
|
|
157
|
+
expect(patches[0]).toHaveProperty('value', 1)
|
|
158
158
|
})
|
|
159
159
|
|
|
160
|
-
it(
|
|
160
|
+
it('path uses JSON pointer format with leading slash', () => {
|
|
161
161
|
const c = Counter.create()
|
|
162
162
|
const patches: Patch[] = []
|
|
163
163
|
onPatch(c, (p) => patches.push(p))
|
|
@@ -165,16 +165,16 @@ describe("onPatch — patch format", () => {
|
|
|
165
165
|
expect(patches[0]!.path).toMatch(/^\//)
|
|
166
166
|
})
|
|
167
167
|
|
|
168
|
-
it(
|
|
169
|
-
const app = App.create({ profile: { name:
|
|
168
|
+
it('nested model patches have composite paths', () => {
|
|
169
|
+
const app = App.create({ profile: { name: 'A', bio: '' }, title: '' })
|
|
170
170
|
const patches: Patch[] = []
|
|
171
171
|
onPatch(app, (p) => patches.push(p))
|
|
172
172
|
|
|
173
|
-
app.profile().rename(
|
|
174
|
-
expect(patches[0]!.path).toBe(
|
|
173
|
+
app.profile().rename('B')
|
|
174
|
+
expect(patches[0]!.path).toBe('/profile/name')
|
|
175
175
|
})
|
|
176
176
|
|
|
177
|
-
it(
|
|
177
|
+
it('value contains new value after mutation, not old', () => {
|
|
178
178
|
const c = Counter.create({ count: 10 })
|
|
179
179
|
const patches: Patch[] = []
|
|
180
180
|
onPatch(c, (p) => patches.push(p))
|
|
@@ -183,7 +183,7 @@ describe("onPatch — patch format", () => {
|
|
|
183
183
|
expect(patches[0]!.value).toBe(15)
|
|
184
184
|
})
|
|
185
185
|
|
|
186
|
-
it(
|
|
186
|
+
it('emits patches for each signal write in sequence', () => {
|
|
187
187
|
const c = Counter.create()
|
|
188
188
|
const patches: Patch[] = []
|
|
189
189
|
onPatch(c, (p) => patches.push(p))
|
|
@@ -199,32 +199,32 @@ describe("onPatch — patch format", () => {
|
|
|
199
199
|
|
|
200
200
|
// ─── applyPatch — applies patches correctly ────────────────────────────────
|
|
201
201
|
|
|
202
|
-
describe(
|
|
203
|
-
it(
|
|
202
|
+
describe('applyPatch — applies patches', () => {
|
|
203
|
+
it('applies a single replace patch to top-level field', () => {
|
|
204
204
|
const c = Counter.create()
|
|
205
|
-
applyPatch(c, { op:
|
|
205
|
+
applyPatch(c, { op: 'replace', path: '/count', value: 42 })
|
|
206
206
|
expect(c.count()).toBe(42)
|
|
207
207
|
})
|
|
208
208
|
|
|
209
|
-
it(
|
|
209
|
+
it('applies array of patches in order', () => {
|
|
210
210
|
const c = Counter.create()
|
|
211
211
|
applyPatch(c, [
|
|
212
|
-
{ op:
|
|
213
|
-
{ op:
|
|
212
|
+
{ op: 'replace', path: '/count', value: 5 },
|
|
213
|
+
{ op: 'replace', path: '/count', value: 10 },
|
|
214
214
|
])
|
|
215
215
|
expect(c.count()).toBe(10)
|
|
216
216
|
})
|
|
217
217
|
|
|
218
|
-
it(
|
|
218
|
+
it('applies patches to nested model instances', () => {
|
|
219
219
|
const app = App.create({
|
|
220
|
-
profile: { name:
|
|
221
|
-
title:
|
|
220
|
+
profile: { name: 'A', bio: 'b' },
|
|
221
|
+
title: 't',
|
|
222
222
|
})
|
|
223
|
-
applyPatch(app, { op:
|
|
224
|
-
expect(app.profile().name()).toBe(
|
|
223
|
+
applyPatch(app, { op: 'replace', path: '/profile/name', value: 'B' })
|
|
224
|
+
expect(app.profile().name()).toBe('B')
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
-
it(
|
|
227
|
+
it('roundtrip: record patches with onPatch, replay on fresh instance', () => {
|
|
228
228
|
const original = Counter.create()
|
|
229
229
|
const patches: Patch[] = []
|
|
230
230
|
onPatch(original, (p) => patches.push({ ...p }))
|
|
@@ -239,36 +239,36 @@ describe("applyPatch — applies patches", () => {
|
|
|
239
239
|
expect(getSnapshot(replica)).toEqual(getSnapshot(original))
|
|
240
240
|
})
|
|
241
241
|
|
|
242
|
-
it(
|
|
242
|
+
it('throws for unsupported op', () => {
|
|
243
243
|
const c = Counter.create()
|
|
244
|
-
expect(() => applyPatch(c, { op:
|
|
245
|
-
|
|
244
|
+
expect(() => applyPatch(c, { op: 'add' as any, path: '/count', value: 1 })).toThrow(
|
|
245
|
+
'unsupported op',
|
|
246
246
|
)
|
|
247
247
|
})
|
|
248
248
|
|
|
249
|
-
it(
|
|
249
|
+
it('throws for empty path', () => {
|
|
250
250
|
const c = Counter.create()
|
|
251
|
-
expect(() => applyPatch(c, { op:
|
|
251
|
+
expect(() => applyPatch(c, { op: 'replace', path: '', value: 1 })).toThrow('empty path')
|
|
252
252
|
})
|
|
253
253
|
|
|
254
|
-
it(
|
|
254
|
+
it('throws for unknown key', () => {
|
|
255
255
|
const c = Counter.create()
|
|
256
|
-
expect(() => applyPatch(c, { op:
|
|
257
|
-
|
|
256
|
+
expect(() => applyPatch(c, { op: 'replace', path: '/unknown', value: 1 })).toThrow(
|
|
257
|
+
'unknown state key',
|
|
258
258
|
)
|
|
259
259
|
})
|
|
260
260
|
|
|
261
|
-
it(
|
|
262
|
-
expect(() => applyPatch({}, { op:
|
|
263
|
-
|
|
261
|
+
it('throws for non-model instance', () => {
|
|
262
|
+
expect(() => applyPatch({}, { op: 'replace', path: '/x', value: 1 })).toThrow(
|
|
263
|
+
'not a model instance',
|
|
264
264
|
)
|
|
265
265
|
})
|
|
266
266
|
})
|
|
267
267
|
|
|
268
268
|
// ─── addMiddleware — intercepts actions ────────────────────────────────────
|
|
269
269
|
|
|
270
|
-
describe(
|
|
271
|
-
it(
|
|
270
|
+
describe('addMiddleware — intercepts actions', () => {
|
|
271
|
+
it('captures action name and args', () => {
|
|
272
272
|
const c = Counter.create()
|
|
273
273
|
const calls: { name: string; args: unknown[] }[] = []
|
|
274
274
|
addMiddleware(c, (call, next) => {
|
|
@@ -276,10 +276,10 @@ describe("addMiddleware — intercepts actions", () => {
|
|
|
276
276
|
return next(call)
|
|
277
277
|
})
|
|
278
278
|
c.add(5)
|
|
279
|
-
expect(calls).toEqual([{ name:
|
|
279
|
+
expect(calls).toEqual([{ name: 'add', args: [5] }])
|
|
280
280
|
})
|
|
281
281
|
|
|
282
|
-
it(
|
|
282
|
+
it('middleware can block action by not calling next', () => {
|
|
283
283
|
const c = Counter.create()
|
|
284
284
|
addMiddleware(c, () => {
|
|
285
285
|
/* intentionally block */
|
|
@@ -288,10 +288,10 @@ describe("addMiddleware — intercepts actions", () => {
|
|
|
288
288
|
expect(c.count()).toBe(0)
|
|
289
289
|
})
|
|
290
290
|
|
|
291
|
-
it(
|
|
291
|
+
it('middleware can modify args', () => {
|
|
292
292
|
const c = Counter.create()
|
|
293
293
|
addMiddleware(c, (call, next) => {
|
|
294
|
-
if (call.name ===
|
|
294
|
+
if (call.name === 'add') {
|
|
295
295
|
return next({ ...call, args: [(call.args[0] as number) * 3] })
|
|
296
296
|
}
|
|
297
297
|
return next(call)
|
|
@@ -300,7 +300,7 @@ describe("addMiddleware — intercepts actions", () => {
|
|
|
300
300
|
expect(c.count()).toBe(15) // 5 * 3
|
|
301
301
|
})
|
|
302
302
|
|
|
303
|
-
it(
|
|
303
|
+
it('unsub removes the middleware', () => {
|
|
304
304
|
const c = Counter.create()
|
|
305
305
|
const log: string[] = []
|
|
306
306
|
const unsub = addMiddleware(c, (call, next) => {
|
|
@@ -315,30 +315,30 @@ describe("addMiddleware — intercepts actions", () => {
|
|
|
315
315
|
expect(log).toHaveLength(1) // no new entries
|
|
316
316
|
})
|
|
317
317
|
|
|
318
|
-
it(
|
|
318
|
+
it('multiple middlewares execute in Koa-style onion order', () => {
|
|
319
319
|
const c = Counter.create()
|
|
320
320
|
const log: string[] = []
|
|
321
321
|
addMiddleware(c, (call, next) => {
|
|
322
|
-
log.push(
|
|
322
|
+
log.push('A:before')
|
|
323
323
|
const r = next(call)
|
|
324
|
-
log.push(
|
|
324
|
+
log.push('A:after')
|
|
325
325
|
return r
|
|
326
326
|
})
|
|
327
327
|
addMiddleware(c, (call, next) => {
|
|
328
|
-
log.push(
|
|
328
|
+
log.push('B:before')
|
|
329
329
|
const r = next(call)
|
|
330
|
-
log.push(
|
|
330
|
+
log.push('B:after')
|
|
331
331
|
return r
|
|
332
332
|
})
|
|
333
333
|
c.inc()
|
|
334
|
-
expect(log).toEqual([
|
|
334
|
+
expect(log).toEqual(['A:before', 'B:before', 'B:after', 'A:after'])
|
|
335
335
|
})
|
|
336
336
|
})
|
|
337
337
|
|
|
338
338
|
// ─── Nested model composition ──────────────────────────────────────────────
|
|
339
339
|
|
|
340
|
-
describe(
|
|
341
|
-
it(
|
|
340
|
+
describe('nested model composition', () => {
|
|
341
|
+
it('deeply nested models work correctly', () => {
|
|
342
342
|
const Leaf = model({
|
|
343
343
|
state: { val: 0 },
|
|
344
344
|
actions: (self) => ({
|
|
@@ -346,26 +346,26 @@ describe("nested model composition", () => {
|
|
|
346
346
|
}),
|
|
347
347
|
})
|
|
348
348
|
const Branch = model({
|
|
349
|
-
state: { leaf: Leaf, tag:
|
|
349
|
+
state: { leaf: Leaf, tag: '' },
|
|
350
350
|
actions: (self) => ({
|
|
351
351
|
setTag: (t: string) => self.tag.set(t),
|
|
352
352
|
}),
|
|
353
353
|
})
|
|
354
354
|
const Root = model({
|
|
355
|
-
state: { branch: Branch, name:
|
|
355
|
+
state: { branch: Branch, name: 'root' },
|
|
356
356
|
})
|
|
357
357
|
|
|
358
358
|
const root = Root.create({
|
|
359
|
-
branch: { leaf: { val: 42 }, tag:
|
|
360
|
-
name:
|
|
359
|
+
branch: { leaf: { val: 42 }, tag: 'test' },
|
|
360
|
+
name: 'myRoot',
|
|
361
361
|
})
|
|
362
362
|
|
|
363
363
|
expect(root.branch().leaf().val()).toBe(42)
|
|
364
|
-
expect(root.branch().tag()).toBe(
|
|
365
|
-
expect(root.name()).toBe(
|
|
364
|
+
expect(root.branch().tag()).toBe('test')
|
|
365
|
+
expect(root.name()).toBe('myRoot')
|
|
366
366
|
})
|
|
367
367
|
|
|
368
|
-
it(
|
|
368
|
+
it('nested model patches propagate up with correct paths', () => {
|
|
369
369
|
const Leaf = model({
|
|
370
370
|
state: { val: 0 },
|
|
371
371
|
actions: (self) => ({
|
|
@@ -385,11 +385,11 @@ describe("nested model composition", () => {
|
|
|
385
385
|
|
|
386
386
|
root.branch().leaf().setVal(99)
|
|
387
387
|
expect(patches).toHaveLength(1)
|
|
388
|
-
expect(patches[0]!.path).toBe(
|
|
388
|
+
expect(patches[0]!.path).toBe('/branch/leaf/val')
|
|
389
389
|
expect(patches[0]!.value).toBe(99)
|
|
390
390
|
})
|
|
391
391
|
|
|
392
|
-
it(
|
|
392
|
+
it('nested getSnapshot serializes all levels', () => {
|
|
393
393
|
const Leaf = model({ state: { x: 1 } })
|
|
394
394
|
const Mid = model({ state: { leaf: Leaf, y: 2 } })
|
|
395
395
|
const Top = model({ state: { mid: Mid, z: 3 } })
|
|
@@ -401,53 +401,53 @@ describe("nested model composition", () => {
|
|
|
401
401
|
})
|
|
402
402
|
})
|
|
403
403
|
|
|
404
|
-
it(
|
|
404
|
+
it('applyPatch to deeply nested path works', () => {
|
|
405
405
|
const Leaf = model({ state: { x: 0 } })
|
|
406
406
|
const Mid = model({ state: { leaf: Leaf } })
|
|
407
407
|
const Top = model({ state: { mid: Mid } })
|
|
408
408
|
|
|
409
409
|
const top = Top.create()
|
|
410
|
-
applyPatch(top, { op:
|
|
410
|
+
applyPatch(top, { op: 'replace', path: '/mid/leaf/x', value: 999 })
|
|
411
411
|
expect(top.mid().leaf().x()).toBe(999)
|
|
412
412
|
})
|
|
413
413
|
})
|
|
414
414
|
|
|
415
415
|
// ─── asHook — singleton hook ───────────────────────────────────────────────
|
|
416
416
|
|
|
417
|
-
describe(
|
|
417
|
+
describe('asHook — creates singleton hook', () => {
|
|
418
418
|
afterEach(() => resetAllHooks())
|
|
419
419
|
|
|
420
|
-
it(
|
|
421
|
-
const useC = Counter.asHook(
|
|
420
|
+
it('returns the same instance every time', () => {
|
|
421
|
+
const useC = Counter.asHook('hook-same')
|
|
422
422
|
const a = useC()
|
|
423
423
|
const b = useC()
|
|
424
424
|
expect(a).toBe(b)
|
|
425
425
|
})
|
|
426
426
|
|
|
427
|
-
it(
|
|
428
|
-
const useC = Counter.asHook(
|
|
427
|
+
it('state mutations persist across calls', () => {
|
|
428
|
+
const useC = Counter.asHook('hook-persist')
|
|
429
429
|
useC().add(10)
|
|
430
430
|
expect(useC().count()).toBe(10)
|
|
431
431
|
})
|
|
432
432
|
|
|
433
|
-
it(
|
|
434
|
-
const useA = Counter.asHook(
|
|
435
|
-
const useB = Counter.asHook(
|
|
433
|
+
it('different ids yield independent instances', () => {
|
|
434
|
+
const useA = Counter.asHook('hook-id-a')
|
|
435
|
+
const useB = Counter.asHook('hook-id-b')
|
|
436
436
|
useA().add(5)
|
|
437
437
|
expect(useA().count()).toBe(5)
|
|
438
438
|
expect(useB().count()).toBe(0)
|
|
439
439
|
})
|
|
440
440
|
|
|
441
|
-
it(
|
|
442
|
-
const useC = Counter.asHook(
|
|
441
|
+
it('resetHook clears specific singleton', () => {
|
|
442
|
+
const useC = Counter.asHook('hook-reset-2')
|
|
443
443
|
useC().add(100)
|
|
444
|
-
resetHook(
|
|
444
|
+
resetHook('hook-reset-2')
|
|
445
445
|
expect(useC().count()).toBe(0)
|
|
446
446
|
})
|
|
447
447
|
|
|
448
|
-
it(
|
|
449
|
-
const useA = Counter.asHook(
|
|
450
|
-
const useB = Counter.asHook(
|
|
448
|
+
it('resetAllHooks clears all singletons', () => {
|
|
449
|
+
const useA = Counter.asHook('hook-all-1')
|
|
450
|
+
const useB = Counter.asHook('hook-all-2')
|
|
451
451
|
useA().add(5)
|
|
452
452
|
useB().add(10)
|
|
453
453
|
|
|
@@ -460,8 +460,8 @@ describe("asHook — creates singleton hook", () => {
|
|
|
460
460
|
|
|
461
461
|
// ─── Effect reactivity ─────────────────────────────────────────────────────
|
|
462
462
|
|
|
463
|
-
describe(
|
|
464
|
-
it(
|
|
463
|
+
describe('effect reactivity with model instances', () => {
|
|
464
|
+
it('effect tracks signal reads from model instance', () => {
|
|
465
465
|
const c = Counter.create()
|
|
466
466
|
const observed: number[] = []
|
|
467
467
|
effect(() => {
|
|
@@ -472,7 +472,7 @@ describe("effect reactivity with model instances", () => {
|
|
|
472
472
|
expect(observed).toEqual([0, 1, 2])
|
|
473
473
|
})
|
|
474
474
|
|
|
475
|
-
it(
|
|
475
|
+
it('effect tracks computed views', () => {
|
|
476
476
|
const c = Counter.create({ count: 1 })
|
|
477
477
|
const observed: number[] = []
|
|
478
478
|
effect(() => {
|