@playfast/reform 0.0.5 → 0.0.8
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 +31 -10
- package/src/boundary/boundary.test.ts +301 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncData.ts +9 -1
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/channel/channel.ts +9 -5
- package/src/compose/host.ts +4 -1
- package/src/compose/props.ts +5 -1
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +173 -44
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +2 -0
- package/src/event/event.test.ts +23 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +38 -2
- package/src/internal/capture.ts +4 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +92 -16
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/queryDriver.ts +1 -1
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/scheduler.ts +3 -1
- package/src/internal/sources.ts +2 -2
- package/src/internal/stateRegistry.ts +32 -0
- package/src/internal/store.test.ts +80 -0
- package/src/internal/track.ts +3 -1
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +6 -2
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +4 -2
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +4 -1
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +4 -1
- package/src/state/stateGroup.ts +31 -4
- package/src/state/token.ts +5 -4
- package/src/synced/syncedStore.ts +99 -0
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
|
+
import { Calc, State, StateGroup } from '../index'
|
|
4
|
+
|
|
5
|
+
// The calc store memoizes on input identity and — the behaviour under test —
|
|
6
|
+
// only wakes its subscribers when a source change actually moves the output.
|
|
7
|
+
|
|
8
|
+
const tick = Effect.sleep(Duration.millis(1))
|
|
9
|
+
|
|
10
|
+
it.live('a source change that does not move the output notifies no subscriber', () => {
|
|
11
|
+
class Count extends State.make('count', S.Number) {}
|
|
12
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
13
|
+
// `isPositive` is stable across 1 -> 2 (still true) but flips on 2 -> -1.
|
|
14
|
+
class IsPositive extends Calc.make('IsPositive', {
|
|
15
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
16
|
+
output: S.Boolean,
|
|
17
|
+
}) {}
|
|
18
|
+
const runs = { n: 0 }
|
|
19
|
+
const IsPositiveLive = Calc.live(IsPositive, ({ count }) => {
|
|
20
|
+
runs.n += 1
|
|
21
|
+
return count > 0
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const TestLayer = IsPositiveLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
25
|
+
|
|
26
|
+
return Effect.gen(function* () {
|
|
27
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
28
|
+
const derived = yield* IsPositive.store
|
|
29
|
+
const notifications = { n: 0 }
|
|
30
|
+
derived.subscribe(() => {
|
|
31
|
+
notifications.n += 1
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
expect(derived.get()).toBe(true)
|
|
35
|
+
|
|
36
|
+
// 1 -> 2: inputs changed, recompute runs, but the output (true) is unchanged.
|
|
37
|
+
source.set(2)
|
|
38
|
+
yield* tick
|
|
39
|
+
expect(notifications.n).toBe(0)
|
|
40
|
+
expect(derived.get()).toBe(true)
|
|
41
|
+
|
|
42
|
+
// 2 -> -1: output flips to false — exactly one notification.
|
|
43
|
+
source.set(-1)
|
|
44
|
+
yield* tick
|
|
45
|
+
expect(notifications.n).toBe(1)
|
|
46
|
+
expect(derived.get()).toBe(false)
|
|
47
|
+
}).pipe(Effect.provide(TestLayer))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it.live('reading does not recompute while inputs are unchanged (memoized)', () => {
|
|
51
|
+
class Count extends State.make('count', S.Number) {}
|
|
52
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
53
|
+
class Doubled extends Calc.make('Doubled', {
|
|
54
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
55
|
+
output: S.Number,
|
|
56
|
+
}) {}
|
|
57
|
+
const runs = { n: 0 }
|
|
58
|
+
const DoubledLive = Calc.live(Doubled, ({ count }) => {
|
|
59
|
+
runs.n += 1
|
|
60
|
+
return count * 2
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
|
|
64
|
+
|
|
65
|
+
return Effect.gen(function* () {
|
|
66
|
+
const derived = yield* Doubled.store
|
|
67
|
+
const before = runs.n
|
|
68
|
+
// Repeated reads with no input change hit the memo — no extra compute.
|
|
69
|
+
expect(derived.get()).toBe(10)
|
|
70
|
+
expect(derived.get()).toBe(10)
|
|
71
|
+
expect(runs.n).toBe(before)
|
|
72
|
+
}).pipe(Effect.provide(TestLayer))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it.live('a calc can depend on another calc as an input source', () => {
|
|
76
|
+
class Count extends State.make('count', S.Number) {}
|
|
77
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
78
|
+
class Doubled extends Calc.make('Doubled', {
|
|
79
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
80
|
+
output: S.Number,
|
|
81
|
+
}) {}
|
|
82
|
+
const DoubledLive = Calc.live(Doubled, ({ count }) => count * 2)
|
|
83
|
+
// `Doubled` (a Calc) is used directly as an input source; its name is the key.
|
|
84
|
+
class PlusDoubled extends Calc.make('PlusDoubled', {
|
|
85
|
+
inputs: [StateGroup.select(Inputs, 'count'), Doubled],
|
|
86
|
+
output: S.Number,
|
|
87
|
+
}) {}
|
|
88
|
+
const PlusDoubledLive = Calc.live(PlusDoubled, ({ count, Doubled }) => count + Doubled)
|
|
89
|
+
|
|
90
|
+
const TestLayer = PlusDoubledLive.pipe(
|
|
91
|
+
Layer.provideMerge(DoubledLive),
|
|
92
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return Effect.gen(function* () {
|
|
96
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
97
|
+
const derived = yield* PlusDoubled.store
|
|
98
|
+
expect(derived.get()).toBe(15) // 5 + (5*2)
|
|
99
|
+
source.set(3)
|
|
100
|
+
yield* tick
|
|
101
|
+
expect(derived.get()).toBe(9) // 3 + (3*2)
|
|
102
|
+
}).pipe(Effect.provide(TestLayer))
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it.live('a diamond dependency resolves in one flush with a single leaf notification', () => {
|
|
106
|
+
// count ─┬─► A (count+1) ─┐
|
|
107
|
+
// └─► B (count+10)─┴─► C (A+B). A change to `count` moves both A and B;
|
|
108
|
+
// C must converge to the new value and wake its subscriber exactly once.
|
|
109
|
+
class Count extends State.make('count', S.Number) {}
|
|
110
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
111
|
+
class A extends Calc.make('A', {
|
|
112
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
113
|
+
output: S.Number,
|
|
114
|
+
}) {}
|
|
115
|
+
const ALive = Calc.live(A, ({ count }) => count + 1)
|
|
116
|
+
class B extends Calc.make('B', {
|
|
117
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
118
|
+
output: S.Number,
|
|
119
|
+
}) {}
|
|
120
|
+
const BLive = Calc.live(B, ({ count }) => count + 10)
|
|
121
|
+
class C extends Calc.make('C', { inputs: [A, B], output: S.Number }) {}
|
|
122
|
+
const CLive = Calc.live(C, ({ A, B }) => A + B)
|
|
123
|
+
|
|
124
|
+
const TestLayer = CLive.pipe(
|
|
125
|
+
Layer.provideMerge(Layer.mergeAll(ALive, BLive)),
|
|
126
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 0 })),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return Effect.gen(function* () {
|
|
130
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
131
|
+
const c = yield* C.store
|
|
132
|
+
const wakes = { n: 0 }
|
|
133
|
+
c.subscribe(() => {
|
|
134
|
+
wakes.n += 1
|
|
135
|
+
})
|
|
136
|
+
expect(c.get()).toBe(11) // (0+1) + (0+10)
|
|
137
|
+
|
|
138
|
+
source.set(5)
|
|
139
|
+
yield* tick
|
|
140
|
+
expect(c.get()).toBe(21) // (5+1) + (5+10)
|
|
141
|
+
expect(wakes.n).toBe(1)
|
|
142
|
+
}).pipe(Effect.provide(TestLayer))
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it.live('invalidateBy: a calc recomputes only when the projected key moves', () => {
|
|
146
|
+
class A extends State.make('a', S.Number) {}
|
|
147
|
+
class B extends State.make('b', S.Number) {}
|
|
148
|
+
class Inputs extends StateGroup.make(A, B) {}
|
|
149
|
+
const runs = { n: 0 }
|
|
150
|
+
class Sum extends Calc.make('Sum', {
|
|
151
|
+
inputs: [StateGroup.select(Inputs, 'a'), StateGroup.select(Inputs, 'b')],
|
|
152
|
+
output: S.Number,
|
|
153
|
+
}) {}
|
|
154
|
+
const SumLive = Calc.live(
|
|
155
|
+
Sum,
|
|
156
|
+
({ a, b }) => {
|
|
157
|
+
runs.n += 1
|
|
158
|
+
return a + b
|
|
159
|
+
},
|
|
160
|
+
{ invalidateBy: ({ a }) => [a] }, // only `a` is in the key
|
|
161
|
+
)
|
|
162
|
+
const TestLayer = SumLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { a: 1, b: 1 })))
|
|
163
|
+
|
|
164
|
+
return Effect.gen(function* () {
|
|
165
|
+
const sa = yield* StateGroup.select(Inputs, 'a').store
|
|
166
|
+
const sb = yield* StateGroup.select(Inputs, 'b').store
|
|
167
|
+
const sum = yield* Sum.store
|
|
168
|
+
const base = runs.n
|
|
169
|
+
expect(sum.get()).toBe(2)
|
|
170
|
+
|
|
171
|
+
// `b` moves but the key (just `a`) is unchanged — no recompute, value retained.
|
|
172
|
+
sb.set(100)
|
|
173
|
+
yield* tick
|
|
174
|
+
expect(runs.n).toBe(base)
|
|
175
|
+
expect(sum.get()).toBe(2)
|
|
176
|
+
|
|
177
|
+
// `a` moves — recompute, now seeing the current `b`.
|
|
178
|
+
sa.set(5)
|
|
179
|
+
yield* tick
|
|
180
|
+
expect(sum.get()).toBe(105)
|
|
181
|
+
}).pipe(Effect.provide(TestLayer))
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it.live('inputs are keyed by the make() name even when the subclass binding differs', () => {
|
|
185
|
+
class Count extends State.make('count', S.Number) {}
|
|
186
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
187
|
+
// The binding name ('Renamed…') deliberately differs from the make() name
|
|
188
|
+
// ('feed') — a class declaration defines its OWN static `name` from the
|
|
189
|
+
// binding, shadowing the explicit one. Minifiers rename bindings, so keying
|
|
190
|
+
// the snapshot off the static would make this input undefined in production
|
|
191
|
+
// builds; the inputs object must key by the manifest's make() name.
|
|
192
|
+
class RenamedByTheMinifier extends Calc.make('feed', {
|
|
193
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
194
|
+
output: S.Number,
|
|
195
|
+
}) {}
|
|
196
|
+
class Downstream extends Calc.make('Downstream', {
|
|
197
|
+
inputs: [RenamedByTheMinifier],
|
|
198
|
+
output: S.Number,
|
|
199
|
+
}) {}
|
|
200
|
+
const seen: Array<unknown> = []
|
|
201
|
+
const FeedLive = Calc.live(RenamedByTheMinifier, ({ count }) => count * 2)
|
|
202
|
+
const DownstreamLive = Calc.live(Downstream, ({ feed }) => {
|
|
203
|
+
seen.push(feed)
|
|
204
|
+
return feed + 1
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const TestLayer = DownstreamLive.pipe(
|
|
208
|
+
Layer.provideMerge(FeedLive),
|
|
209
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 3 })),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return Effect.gen(function* () {
|
|
213
|
+
// The subclass's own static name is the (would-be-minified) binding name…
|
|
214
|
+
expect(RenamedByTheMinifier.name).toBe('RenamedByTheMinifier')
|
|
215
|
+
// …but the snapshot keys by the make() name, so `feed` is defined.
|
|
216
|
+
const downstream = yield* Downstream.store
|
|
217
|
+
expect(downstream.get()).toBe(7)
|
|
218
|
+
expect(seen.every((v) => v !== undefined)).toBe(true)
|
|
219
|
+
}).pipe(Effect.provide(TestLayer))
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it.live('reuse: an unchanged subtree keeps its identity across recompute', () => {
|
|
223
|
+
class A extends State.make('a', S.Number) {}
|
|
224
|
+
class B extends State.make('b', S.Number) {}
|
|
225
|
+
class Inputs extends StateGroup.make(A, B) {}
|
|
226
|
+
const Pair = S.Struct({
|
|
227
|
+
left: S.Struct({ value: S.Number }),
|
|
228
|
+
right: S.Struct({ value: S.Number }),
|
|
229
|
+
})
|
|
230
|
+
class Split extends Calc.make('Split', {
|
|
231
|
+
inputs: [StateGroup.select(Inputs, 'a'), StateGroup.select(Inputs, 'b')],
|
|
232
|
+
output: Pair,
|
|
233
|
+
}) {}
|
|
234
|
+
const SplitLive = Calc.live(
|
|
235
|
+
Split,
|
|
236
|
+
({ a, b }) => ({ left: { value: a }, right: { value: b } }),
|
|
237
|
+
{ reuse: true },
|
|
238
|
+
)
|
|
239
|
+
const TestLayer = SplitLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { a: 1, b: 1 })))
|
|
240
|
+
|
|
241
|
+
return Effect.gen(function* () {
|
|
242
|
+
const b = yield* StateGroup.select(Inputs, 'b').store
|
|
243
|
+
const derived = yield* Split.store
|
|
244
|
+
const before = derived.get()
|
|
245
|
+
|
|
246
|
+
// Only `b` moves: the root and `right` are fresh, `left` keeps its identity.
|
|
247
|
+
b.set(2)
|
|
248
|
+
yield* tick
|
|
249
|
+
const after = derived.get()
|
|
250
|
+
expect(after).not.toBe(before)
|
|
251
|
+
expect(after.left).toBe(before.left)
|
|
252
|
+
expect(after.right).not.toBe(before.right)
|
|
253
|
+
expect(after.right.value).toBe(2)
|
|
254
|
+
}).pipe(Effect.provide(TestLayer))
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it.live('reuse: a value-equal recompute keeps the previous reference and wakes nobody', () => {
|
|
258
|
+
class Count extends State.make('count', S.Number) {}
|
|
259
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
260
|
+
const Box = S.Struct({ positive: S.Boolean })
|
|
261
|
+
class IsPositive extends Calc.make('IsPositive', {
|
|
262
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
263
|
+
output: Box,
|
|
264
|
+
}) {}
|
|
265
|
+
// A plain-object output: WITHOUT reuse every recompute is a fresh identity
|
|
266
|
+
// (and would notify); with it, a value-equal recompute returns the previous
|
|
267
|
+
// reference and the Equal gate stays silent.
|
|
268
|
+
const IsPositiveLive = Calc.live(IsPositive, ({ count }) => ({ positive: count > 0 }), {
|
|
269
|
+
reuse: true,
|
|
270
|
+
})
|
|
271
|
+
const TestLayer = IsPositiveLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
272
|
+
|
|
273
|
+
return Effect.gen(function* () {
|
|
274
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
275
|
+
const derived = yield* IsPositive.store
|
|
276
|
+
const notifications = { n: 0 }
|
|
277
|
+
derived.subscribe(() => {
|
|
278
|
+
notifications.n += 1
|
|
279
|
+
})
|
|
280
|
+
const before = derived.get()
|
|
281
|
+
|
|
282
|
+
source.set(2)
|
|
283
|
+
yield* tick
|
|
284
|
+
expect(derived.get()).toBe(before)
|
|
285
|
+
expect(notifications.n).toBe(0)
|
|
286
|
+
}).pipe(Effect.provide(TestLayer))
|
|
287
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Context, Duration, Effect, Exit, Layer, Schema as S, Scope } from 'effect'
|
|
3
|
+
import { CalcFamily, State, StateGroup } from '../index'
|
|
4
|
+
|
|
5
|
+
// CalcFamily: per-key derived stores over shared inputs. The behaviours under
|
|
6
|
+
// test: members derive + memoize independently, a member only notifies when ITS
|
|
7
|
+
// output moves, and every removal path releases the member's upstream
|
|
8
|
+
// subscription (a derived member holds one; a StateFamily entry doesn't).
|
|
9
|
+
|
|
10
|
+
const tick = Effect.sleep(Duration.millis(1))
|
|
11
|
+
|
|
12
|
+
// Shared fixture shape: a record of named counters; each member projects one.
|
|
13
|
+
const Board = S.Record({ key: S.String, value: S.Number })
|
|
14
|
+
|
|
15
|
+
const fixture = () => {
|
|
16
|
+
class Feed extends State.make('feed', Board) {}
|
|
17
|
+
class Inputs extends StateGroup.make(Feed) {}
|
|
18
|
+
const runs = new Map<string, number>()
|
|
19
|
+
class Slice extends CalcFamily.make('Slice', {
|
|
20
|
+
key: S.String,
|
|
21
|
+
inputs: [StateGroup.select(Inputs, 'feed')],
|
|
22
|
+
output: S.Number,
|
|
23
|
+
}) {}
|
|
24
|
+
const SliceLive = CalcFamily.live(Slice, (key) => ({ feed }) => {
|
|
25
|
+
runs.set(key, (runs.get(key) ?? 0) + 1)
|
|
26
|
+
return feed[key] ?? 0
|
|
27
|
+
})
|
|
28
|
+
return { Inputs, Slice, SliceLive, runs }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it.live('a member derives from the shared inputs and recomputes when they change', () => {
|
|
32
|
+
const { Inputs, Slice, SliceLive } = fixture()
|
|
33
|
+
const TestLayer = SliceLive.pipe(
|
|
34
|
+
Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1, b: 10 } })),
|
|
35
|
+
)
|
|
36
|
+
return Effect.gen(function* () {
|
|
37
|
+
const feed = yield* StateGroup.select(Inputs, 'feed').store
|
|
38
|
+
expect(yield* CalcFamily.read(Slice, 'a')).toBe(1)
|
|
39
|
+
expect(yield* CalcFamily.read(Slice, 'b')).toBe(10)
|
|
40
|
+
|
|
41
|
+
feed.set({ a: 2, b: 10 })
|
|
42
|
+
yield* tick
|
|
43
|
+
expect(yield* CalcFamily.read(Slice, 'a')).toBe(2)
|
|
44
|
+
}).pipe(Effect.provide(TestLayer))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it.live('a member only notifies when its own slice of the projection moves', () => {
|
|
48
|
+
const { Inputs, Slice, SliceLive } = fixture()
|
|
49
|
+
const TestLayer = SliceLive.pipe(
|
|
50
|
+
Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1, b: 10 } })),
|
|
51
|
+
)
|
|
52
|
+
return Effect.gen(function* () {
|
|
53
|
+
const feed = yield* StateGroup.select(Inputs, 'feed').store
|
|
54
|
+
const family = yield* Slice.store
|
|
55
|
+
const wakes = { a: 0, b: 0 }
|
|
56
|
+
family.at('a').subscribe(() => {
|
|
57
|
+
wakes.a += 1
|
|
58
|
+
})
|
|
59
|
+
family.at('b').subscribe(() => {
|
|
60
|
+
wakes.b += 1
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Only `a` moves: b's member recomputes but its output is Equal — silent.
|
|
64
|
+
feed.set({ a: 2, b: 10 })
|
|
65
|
+
yield* tick
|
|
66
|
+
expect(wakes).toEqual({ a: 1, b: 0 })
|
|
67
|
+
|
|
68
|
+
feed.set({ a: 2, b: 11 })
|
|
69
|
+
yield* tick
|
|
70
|
+
expect(wakes).toEqual({ a: 1, b: 1 })
|
|
71
|
+
}).pipe(Effect.provide(TestLayer))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it.live('reading does not recompute while the shared inputs are unchanged (memoized per member)', () => {
|
|
75
|
+
const { Inputs, Slice, SliceLive, runs } = fixture()
|
|
76
|
+
const TestLayer = SliceLive.pipe(
|
|
77
|
+
Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
|
|
78
|
+
)
|
|
79
|
+
return Effect.gen(function* () {
|
|
80
|
+
yield* CalcFamily.read(Slice, 'a')
|
|
81
|
+
yield* CalcFamily.read(Slice, 'a')
|
|
82
|
+
yield* CalcFamily.read(Slice, 'a')
|
|
83
|
+
expect(runs.get('a')).toBe(1)
|
|
84
|
+
}).pipe(Effect.provide(TestLayer))
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it.live('invalidateBy: shared-input churn outside the key recomputes no member', () => {
|
|
88
|
+
class Feed extends State.make('feed', Board) {}
|
|
89
|
+
class Noise extends State.make('noise', S.Number) {}
|
|
90
|
+
class Inputs extends StateGroup.make(Feed, Noise) {}
|
|
91
|
+
const runs = new Map<string, number>()
|
|
92
|
+
class Slice extends CalcFamily.make('Slice', {
|
|
93
|
+
key: S.String,
|
|
94
|
+
inputs: [StateGroup.select(Inputs, 'feed'), StateGroup.select(Inputs, 'noise')],
|
|
95
|
+
output: S.Number,
|
|
96
|
+
}) {}
|
|
97
|
+
const SliceLive = CalcFamily.live(
|
|
98
|
+
Slice,
|
|
99
|
+
(key) => ({ feed }) => {
|
|
100
|
+
runs.set(key, (runs.get(key) ?? 0) + 1)
|
|
101
|
+
return feed[key] ?? 0
|
|
102
|
+
},
|
|
103
|
+
{ invalidateBy: ({ feed }) => [feed] },
|
|
104
|
+
)
|
|
105
|
+
const TestLayer = SliceLive.pipe(
|
|
106
|
+
Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 }, noise: 0 })),
|
|
107
|
+
)
|
|
108
|
+
return Effect.gen(function* () {
|
|
109
|
+
const noise = yield* StateGroup.select(Inputs, 'noise').store
|
|
110
|
+
expect(yield* CalcFamily.read(Slice, 'a')).toBe(1)
|
|
111
|
+
expect(runs.get('a')).toBe(1)
|
|
112
|
+
|
|
113
|
+
noise.set(99)
|
|
114
|
+
yield* tick
|
|
115
|
+
expect(yield* CalcFamily.read(Slice, 'a')).toBe(1)
|
|
116
|
+
expect(runs.get('a')).toBe(1)
|
|
117
|
+
}).pipe(Effect.provide(TestLayer))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it.live('forget releases the member and its upstream subscription', () => {
|
|
121
|
+
const { Inputs, Slice, SliceLive, runs } = fixture()
|
|
122
|
+
const TestLayer = SliceLive.pipe(
|
|
123
|
+
Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
|
|
124
|
+
)
|
|
125
|
+
return Effect.gen(function* () {
|
|
126
|
+
const feed = yield* StateGroup.select(Inputs, 'feed').store
|
|
127
|
+
const family = yield* Slice.store
|
|
128
|
+
family.at('a')
|
|
129
|
+
expect(family.size()).toBe(1)
|
|
130
|
+
|
|
131
|
+
family.forget('a')
|
|
132
|
+
expect(family.size()).toBe(0)
|
|
133
|
+
// The dropped member no longer recomputes on upstream churn.
|
|
134
|
+
const before = runs.get('a') ?? 0
|
|
135
|
+
feed.set({ a: 2 })
|
|
136
|
+
yield* tick
|
|
137
|
+
expect(runs.get('a') ?? 0).toBe(before)
|
|
138
|
+
|
|
139
|
+
// Re-`at`-ing allocates a fresh member over the current snapshot.
|
|
140
|
+
expect(family.at('a').get()).toBe(2)
|
|
141
|
+
}).pipe(Effect.provide(TestLayer))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it.live('evictWhenUnused: an idle member is dropped on the next microtask and unsubscribes', () => {
|
|
145
|
+
class Feed extends State.make('feed', Board) {}
|
|
146
|
+
class Inputs extends StateGroup.make(Feed) {}
|
|
147
|
+
const runs = new Map<string, number>()
|
|
148
|
+
class Slice extends CalcFamily.make('Slice', {
|
|
149
|
+
key: S.String,
|
|
150
|
+
inputs: [StateGroup.select(Inputs, 'feed')],
|
|
151
|
+
output: S.Number,
|
|
152
|
+
}) {}
|
|
153
|
+
const SliceLive = CalcFamily.live(
|
|
154
|
+
Slice,
|
|
155
|
+
(key) => ({ feed }) => {
|
|
156
|
+
runs.set(key, (runs.get(key) ?? 0) + 1)
|
|
157
|
+
return feed[key] ?? 0
|
|
158
|
+
},
|
|
159
|
+
{ evictWhenUnused: true },
|
|
160
|
+
)
|
|
161
|
+
const TestLayer = SliceLive.pipe(
|
|
162
|
+
Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
|
|
163
|
+
)
|
|
164
|
+
return Effect.gen(function* () {
|
|
165
|
+
const feed = yield* StateGroup.select(Inputs, 'feed').store
|
|
166
|
+
const family = yield* Slice.store
|
|
167
|
+
const off = family.at('a').subscribe(() => {})
|
|
168
|
+
expect(family.size()).toBe(1)
|
|
169
|
+
|
|
170
|
+
off()
|
|
171
|
+
yield* tick
|
|
172
|
+
expect(family.size()).toBe(0)
|
|
173
|
+
const before = runs.get('a') ?? 0
|
|
174
|
+
feed.set({ a: 2 })
|
|
175
|
+
yield* tick
|
|
176
|
+
expect(runs.get('a') ?? 0).toBe(before)
|
|
177
|
+
}).pipe(Effect.provide(TestLayer))
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it.live('the layer finalizer clears every member (no live subscriptions leak)', () => {
|
|
181
|
+
const { Inputs, Slice, SliceLive, runs } = fixture()
|
|
182
|
+
return Effect.scoped(
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
// Build the input stores once, then the family in its OWN scope over that
|
|
185
|
+
// exact context — so the stores outlive the family and we can prove the
|
|
186
|
+
// members' upstream subscriptions died with it.
|
|
187
|
+
const storesCtx = yield* Layer.build(StateGroup.live(Inputs, { feed: { a: 1, b: 2 } }))
|
|
188
|
+
const feed = Context.get(storesCtx, StateGroup.select(Inputs, 'feed').store)
|
|
189
|
+
const scope = yield* Scope.make()
|
|
190
|
+
const familyCtx = yield* Layer.buildWithScope(SliceLive, scope).pipe(
|
|
191
|
+
Effect.provide(storesCtx),
|
|
192
|
+
)
|
|
193
|
+
const family = Context.get(familyCtx, Slice.store)
|
|
194
|
+
family.at('a')
|
|
195
|
+
family.at('b')
|
|
196
|
+
expect(family.size()).toBe(2)
|
|
197
|
+
|
|
198
|
+
yield* Scope.close(scope, Exit.void)
|
|
199
|
+
expect(family.size()).toBe(0)
|
|
200
|
+
const before = [runs.get('a') ?? 0, runs.get('b') ?? 0]
|
|
201
|
+
feed.set({ a: 9, b: 9 })
|
|
202
|
+
yield* tick
|
|
203
|
+
expect([runs.get('a') ?? 0, runs.get('b') ?? 0]).toEqual(before)
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Effect, Layer, Schema as S } from 'effect'
|
|
3
|
+
import { Calc, composeCalcs, State, StateGroup } from '../index'
|
|
4
|
+
|
|
5
|
+
// `composeCalcs` wires a calc-on-calc chain in dependency order. The shape under
|
|
6
|
+
// test is the one that defeats a flat `Layer.mergeAll`: a linear chain where each
|
|
7
|
+
// calc's only input is the previous calc — count → Doubled → PlusOne → Tripled.
|
|
8
|
+
|
|
9
|
+
it.live('composeCalcs wires a linear calc chain; the result needs only the base state', () => {
|
|
10
|
+
class Count extends State.make('count', S.Number) {}
|
|
11
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
12
|
+
|
|
13
|
+
class Doubled extends Calc.make('Doubled', {
|
|
14
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
15
|
+
output: S.Number,
|
|
16
|
+
}) {}
|
|
17
|
+
const DoubledLive = Calc.live(Doubled, ({ count }) => count * 2)
|
|
18
|
+
|
|
19
|
+
class PlusOne extends Calc.make('PlusOne', { inputs: [Doubled], output: S.Number }) {}
|
|
20
|
+
const PlusOneLive = Calc.live(PlusOne, ({ Doubled }) => Doubled + 1)
|
|
21
|
+
|
|
22
|
+
class Tripled extends Calc.make('Tripled', { inputs: [PlusOne], output: S.Number }) {}
|
|
23
|
+
const TripledLive = Calc.live(Tripled, ({ PlusOne }) => PlusOne * 3)
|
|
24
|
+
|
|
25
|
+
// Leaf-first, then upstreams in dependency order. The composed layer's only
|
|
26
|
+
// remaining requirement is the base `count` store — which the type system
|
|
27
|
+
// proves by letting us provide *only* the state group below.
|
|
28
|
+
// The chain's only remaining requirement is the base `count` store — provide
|
|
29
|
+
// (and expose, via provideMerge) just the state group and everything builds.
|
|
30
|
+
const Chain = composeCalcs(TripledLive, PlusOneLive, DoubledLive)
|
|
31
|
+
const TestLayer = Chain.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
|
|
32
|
+
|
|
33
|
+
return Effect.gen(function* () {
|
|
34
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
35
|
+
const tripled = yield* Tripled.store
|
|
36
|
+
// count 5 → Doubled 10 → PlusOne 11 → Tripled 33
|
|
37
|
+
expect(tripled.get()).toBe(33)
|
|
38
|
+
|
|
39
|
+
source.set(2)
|
|
40
|
+
yield* Effect.yieldNow()
|
|
41
|
+
// count 2 → Doubled 4 → PlusOne 5 → Tripled 15
|
|
42
|
+
expect(tripled.get()).toBe(15)
|
|
43
|
+
}).pipe(Effect.provide(TestLayer))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it.live('composeCalcs(leaf, upstream) matches the hand-written provideMerge chain', () => {
|
|
47
|
+
class Count extends State.make('count', S.Number) {}
|
|
48
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
49
|
+
class Doubled extends Calc.make('Doubled', {
|
|
50
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
51
|
+
output: S.Number,
|
|
52
|
+
}) {}
|
|
53
|
+
const DoubledLive = Calc.live(Doubled, ({ count }) => count * 2)
|
|
54
|
+
class PlusDoubled extends Calc.make('PlusDoubled', {
|
|
55
|
+
inputs: [StateGroup.select(Inputs, 'count'), Doubled],
|
|
56
|
+
output: S.Number,
|
|
57
|
+
}) {}
|
|
58
|
+
const PlusDoubledLive = Calc.live(PlusDoubled, ({ count, Doubled }) => count + Doubled)
|
|
59
|
+
|
|
60
|
+
const TestLayer = composeCalcs(PlusDoubledLive, DoubledLive).pipe(
|
|
61
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return Effect.gen(function* () {
|
|
65
|
+
const derived = yield* PlusDoubled.store
|
|
66
|
+
expect(derived.get()).toBe(15) // 5 + (5*2), same as the manual-pipe test
|
|
67
|
+
}).pipe(Effect.provide(TestLayer))
|
|
68
|
+
})
|
package/src/channel/channel.ts
CHANGED
|
@@ -84,7 +84,9 @@ export interface ProcedureRegistry {
|
|
|
84
84
|
readonly unregister: (entry: ProcedureEntry) => void
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
const ProceduresBase: Context.TagClass<Procedures, 'reform/Procedures', ProcedureRegistry> =
|
|
88
|
+
Context.Tag('reform/Procedures')<Procedures, ProcedureRegistry>()
|
|
89
|
+
export class Procedures extends ProceduresBase {}
|
|
88
90
|
|
|
89
91
|
/** Get a map entry, creating and inserting it on first access (avoids `let`). */
|
|
90
92
|
const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
|
|
@@ -143,7 +145,7 @@ const makeProcedureRegistry = (): ProcedureRegistry => {
|
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
export const proceduresLayer = Layer.sync(Procedures, makeProcedureRegistry)
|
|
148
|
+
export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(Procedures, makeProcedureRegistry)
|
|
147
149
|
|
|
148
150
|
/** A live channel: the single loop offers events here; the channel drives them. */
|
|
149
151
|
export interface ChannelRuntime {
|
|
@@ -154,12 +156,14 @@ export interface ChannelRuntime {
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
/** Registry of live channels, keyed by name. The single loop routes through it. */
|
|
157
|
-
|
|
159
|
+
const ChannelsBase: Context.TagClass<
|
|
158
160
|
Channels,
|
|
161
|
+
'reform/Channels',
|
|
159
162
|
{ readonly byName: Map<string, ChannelRuntime> }
|
|
160
|
-
>() {}
|
|
163
|
+
> = Context.Tag('reform/Channels')<Channels, { readonly byName: Map<string, ChannelRuntime> }>()
|
|
164
|
+
export class Channels extends ChannelsBase {}
|
|
161
165
|
|
|
162
|
-
export const channelsLayer = Layer.sync(Channels, () => ({ byName: new Map() }))
|
|
166
|
+
export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({ byName: new Map() }))
|
|
163
167
|
|
|
164
168
|
/**
|
|
165
169
|
* A scheduling lane for procedures. The *definition* (`Channel.make`) is a
|
package/src/compose/host.ts
CHANGED
|
@@ -11,5 +11,8 @@ export interface SlotHost {
|
|
|
11
11
|
slot(name: string): (props: unknown) => Node
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const CurrentSlotsBase: Context.TagClass<CurrentSlots, 'reform/Slots', SlotHost> =
|
|
15
|
+
Context.Tag('reform/Slots')<CurrentSlots, SlotHost>()
|
|
16
|
+
|
|
14
17
|
/** The active composition's slot bindings, provided per render by the host. */
|
|
15
|
-
export class CurrentSlots extends
|
|
18
|
+
export class CurrentSlots extends CurrentSlotsBase {}
|
package/src/compose/props.ts
CHANGED
|
@@ -7,4 +7,8 @@ import { Context } from 'effect'
|
|
|
7
7
|
* (Per-instance typing of Props is DESIGN #26 — for now the value is opaque and
|
|
8
8
|
* narrowed at the use site against the composition's declared props schema.)
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
const PropsBase: Context.TagClass<Props, 'reform/Props', any> = Context.Tag('reform/Props')<
|
|
11
|
+
Props,
|
|
12
|
+
any
|
|
13
|
+
>()
|
|
14
|
+
export class Props extends PropsBase {}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { expect, test } from 'vitest'
|
|
2
|
+
import { Effect, Schema as S } from 'effect'
|
|
3
|
+
import { Ui, isUi, ui } from '../index'
|
|
4
|
+
import { CaptureSink, type UiCapture } from '../internal/capture'
|
|
5
|
+
import type { Trigger } from '../ui/trigger'
|
|
6
|
+
|
|
7
|
+
// `ui` has two authoring forms with one source of truth each: the type-only form
|
|
8
|
+
// (local, no schema) and the schema form (wired, the type is *derived*). Both are
|
|
9
|
+
// the same contract at runtime; only the wired form carries reflectable schemas.
|
|
10
|
+
|
|
11
|
+
test('type-only `ui` carries no wire schema', () => {
|
|
12
|
+
class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
|
|
13
|
+
expect(CounterUi.manifest.kind).toBe('Ui')
|
|
14
|
+
expect(CounterUi.manifest.name).toBe('Counter')
|
|
15
|
+
expect(CounterUi.manifest.props).toBeUndefined()
|
|
16
|
+
expect(CounterUi.manifest.events).toBeUndefined()
|
|
17
|
+
expect(isUi(CounterUi)).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('schema `ui` carries props and per-event wire schemas', () => {
|
|
21
|
+
class CounterUi extends ui('Counter', {
|
|
22
|
+
props: S.Struct({ count: S.Number }),
|
|
23
|
+
events: { bump: S.Struct({ by: S.Number }) },
|
|
24
|
+
}) {}
|
|
25
|
+
expect(CounterUi.manifest.kind).toBe('Ui')
|
|
26
|
+
expect(CounterUi.manifest.name).toBe('Counter')
|
|
27
|
+
expect(S.isSchema(CounterUi.manifest.props)).toBe(true)
|
|
28
|
+
expect(S.isSchema(CounterUi.manifest.events?.['bump'])).toBe(true)
|
|
29
|
+
expect(isUi(CounterUi)).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('schema `ui` with no events omits the events schema', () => {
|
|
33
|
+
class TitleUi extends ui('Title', { props: S.Struct({ text: S.String }) }) {}
|
|
34
|
+
expect(S.isSchema(TitleUi.manifest.props)).toBe(true)
|
|
35
|
+
expect(TitleUi.manifest.events).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('a contract resolves its presentation and reports to a capturing sink', () => {
|
|
39
|
+
class CounterUi extends ui('Counter', {
|
|
40
|
+
props: S.Struct({ count: S.Number }),
|
|
41
|
+
events: { bump: S.Struct({ by: S.Number }) },
|
|
42
|
+
}) {}
|
|
43
|
+
|
|
44
|
+
const view = Ui.make(CounterUi, ({ count }) => `count:${count}`)
|
|
45
|
+
const records: UiCapture[] = []
|
|
46
|
+
const sink = { record: (capture: UiCapture): void => void records.push(capture) }
|
|
47
|
+
|
|
48
|
+
const logicView = Effect.runSync(
|
|
49
|
+
CounterUi.pipe(
|
|
50
|
+
Effect.provideService(CounterUi.impl, view),
|
|
51
|
+
Effect.provideService(CaptureSink, sink),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const bump: Trigger<{ by: number }> = () => {}
|
|
56
|
+
const node = logicView({ count: 3 }, { bump })
|
|
57
|
+
|
|
58
|
+
expect(node).toBe('count:3')
|
|
59
|
+
expect(records).toHaveLength(1)
|
|
60
|
+
expect(records[0]?.name).toBe('Counter')
|
|
61
|
+
expect(records[0]?.props).toEqual({ count: 3 })
|
|
62
|
+
})
|