@playfast/reform 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
package/src/calc/calc.test.ts
CHANGED
|
@@ -2,15 +2,11 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
3
|
import { Calc, State, StateGroup } from '../index'
|
|
4
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
5
|
const tick = Effect.sleep(Duration.millis(1))
|
|
9
6
|
|
|
10
7
|
it.live('a source change that does not move the output notifies no subscriber', () => {
|
|
11
8
|
class Count extends State.make('count', S.Number) {}
|
|
12
9
|
class Inputs extends StateGroup.make(Count) {}
|
|
13
|
-
// `isPositive` is stable across 1 -> 2 (still true) but flips on 2 -> -1.
|
|
14
10
|
class IsPositive extends Calc.make('IsPositive', {
|
|
15
11
|
inputs: [StateGroup.select(Inputs, 'count')],
|
|
16
12
|
output: S.Boolean,
|
|
@@ -33,13 +29,11 @@ it.live('a source change that does not move the output notifies no subscriber',
|
|
|
33
29
|
|
|
34
30
|
expect(derived.get()).toBe(true)
|
|
35
31
|
|
|
36
|
-
// 1 -> 2: inputs changed, recompute runs, but the output (true) is unchanged.
|
|
37
32
|
source.set(2)
|
|
38
33
|
yield* tick
|
|
39
34
|
expect(notifications.n).toBe(0)
|
|
40
35
|
expect(derived.get()).toBe(true)
|
|
41
36
|
|
|
42
|
-
// 2 -> -1: output flips to false — exactly one notification.
|
|
43
37
|
source.set(-1)
|
|
44
38
|
yield* tick
|
|
45
39
|
expect(notifications.n).toBe(1)
|
|
@@ -65,7 +59,6 @@ it.live('reading does not recompute while inputs are unchanged (memoized)', () =
|
|
|
65
59
|
return Effect.gen(function* () {
|
|
66
60
|
const derived = yield* Doubled.store
|
|
67
61
|
const before = runs.n
|
|
68
|
-
// Repeated reads with no input change hit the memo — no extra compute.
|
|
69
62
|
expect(derived.get()).toBe(10)
|
|
70
63
|
expect(derived.get()).toBe(10)
|
|
71
64
|
expect(runs.n).toBe(before)
|
|
@@ -80,7 +73,6 @@ it.live('a calc can depend on another calc as an input source', () => {
|
|
|
80
73
|
output: S.Number,
|
|
81
74
|
}) {}
|
|
82
75
|
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
76
|
class PlusDoubled extends Calc.make('PlusDoubled', {
|
|
85
77
|
inputs: [StateGroup.select(Inputs, 'count'), Doubled],
|
|
86
78
|
output: S.Number,
|
|
@@ -95,17 +87,14 @@ it.live('a calc can depend on another calc as an input source', () => {
|
|
|
95
87
|
return Effect.gen(function* () {
|
|
96
88
|
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
97
89
|
const derived = yield* PlusDoubled.store
|
|
98
|
-
expect(derived.get()).toBe(15)
|
|
90
|
+
expect(derived.get()).toBe(15)
|
|
99
91
|
source.set(3)
|
|
100
92
|
yield* tick
|
|
101
|
-
expect(derived.get()).toBe(9)
|
|
93
|
+
expect(derived.get()).toBe(9)
|
|
102
94
|
}).pipe(Effect.provide(TestLayer))
|
|
103
95
|
})
|
|
104
96
|
|
|
105
97
|
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
98
|
class Count extends State.make('count', S.Number) {}
|
|
110
99
|
class Inputs extends StateGroup.make(Count) {}
|
|
111
100
|
class A extends Calc.make('A', {
|
|
@@ -133,11 +122,11 @@ it.live('a diamond dependency resolves in one flush with a single leaf notificat
|
|
|
133
122
|
c.subscribe(() => {
|
|
134
123
|
wakes.n += 1
|
|
135
124
|
})
|
|
136
|
-
expect(c.get()).toBe(11)
|
|
125
|
+
expect(c.get()).toBe(11)
|
|
137
126
|
|
|
138
127
|
source.set(5)
|
|
139
128
|
yield* tick
|
|
140
|
-
expect(c.get()).toBe(21)
|
|
129
|
+
expect(c.get()).toBe(21)
|
|
141
130
|
expect(wakes.n).toBe(1)
|
|
142
131
|
}).pipe(Effect.provide(TestLayer))
|
|
143
132
|
})
|
|
@@ -168,13 +157,11 @@ it.live('invalidateBy: a calc recomputes only when the projected key moves', ()
|
|
|
168
157
|
const base = runs.n
|
|
169
158
|
expect(sum.get()).toBe(2)
|
|
170
159
|
|
|
171
|
-
// `b` moves but the key (just `a`) is unchanged — no recompute, value retained.
|
|
172
160
|
sb.set(100)
|
|
173
161
|
yield* tick
|
|
174
162
|
expect(runs.n).toBe(base)
|
|
175
163
|
expect(sum.get()).toBe(2)
|
|
176
164
|
|
|
177
|
-
// `a` moves — recompute, now seeing the current `b`.
|
|
178
165
|
sa.set(5)
|
|
179
166
|
yield* tick
|
|
180
167
|
expect(sum.get()).toBe(105)
|
|
@@ -184,11 +171,6 @@ it.live('invalidateBy: a calc recomputes only when the projected key moves', ()
|
|
|
184
171
|
it.live('inputs are keyed by the make() name even when the subclass binding differs', () => {
|
|
185
172
|
class Count extends State.make('count', S.Number) {}
|
|
186
173
|
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
174
|
class RenamedByTheMinifier extends Calc.make('feed', {
|
|
193
175
|
inputs: [StateGroup.select(Inputs, 'count')],
|
|
194
176
|
output: S.Number,
|
|
@@ -210,9 +192,7 @@ it.live('inputs are keyed by the make() name even when the subclass binding diff
|
|
|
210
192
|
)
|
|
211
193
|
|
|
212
194
|
return Effect.gen(function* () {
|
|
213
|
-
// The subclass's own static name is the (would-be-minified) binding name…
|
|
214
195
|
expect(RenamedByTheMinifier.name).toBe('RenamedByTheMinifier')
|
|
215
|
-
// …but the snapshot keys by the make() name, so `feed` is defined.
|
|
216
196
|
const downstream = yield* Downstream.store
|
|
217
197
|
expect(downstream.get()).toBe(7)
|
|
218
198
|
expect(seen.every((v) => v !== undefined)).toBe(true)
|
|
@@ -231,11 +211,9 @@ it.live('reuse: an unchanged subtree keeps its identity across recompute', () =>
|
|
|
231
211
|
inputs: [StateGroup.select(Inputs, 'a'), StateGroup.select(Inputs, 'b')],
|
|
232
212
|
output: Pair,
|
|
233
213
|
}) {}
|
|
234
|
-
const SplitLive = Calc.live(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
{ reuse: true },
|
|
238
|
-
)
|
|
214
|
+
const SplitLive = Calc.live(Split, ({ a, b }) => ({ left: { value: a }, right: { value: b } }), {
|
|
215
|
+
reuse: true,
|
|
216
|
+
})
|
|
239
217
|
const TestLayer = SplitLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { a: 1, b: 1 })))
|
|
240
218
|
|
|
241
219
|
return Effect.gen(function* () {
|
|
@@ -243,7 +221,6 @@ it.live('reuse: an unchanged subtree keeps its identity across recompute', () =>
|
|
|
243
221
|
const derived = yield* Split.store
|
|
244
222
|
const before = derived.get()
|
|
245
223
|
|
|
246
|
-
// Only `b` moves: the root and `right` are fresh, `left` keeps its identity.
|
|
247
224
|
b.set(2)
|
|
248
225
|
yield* tick
|
|
249
226
|
const after = derived.get()
|
|
@@ -262,9 +239,6 @@ it.live('reuse: a value-equal recompute keeps the previous reference and wakes n
|
|
|
262
239
|
inputs: [StateGroup.select(Inputs, 'count')],
|
|
263
240
|
output: Box,
|
|
264
241
|
}) {}
|
|
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
242
|
const IsPositiveLive = Calc.live(IsPositive, ({ count }) => ({ positive: count > 0 }), {
|
|
269
243
|
reuse: true,
|
|
270
244
|
})
|
package/src/calc/calc.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Context, Effect, Layer, MutableRef, type Schema } from 'effect'
|
|
2
|
+
import type { Effect as EffectType } from 'effect/Effect'
|
|
2
3
|
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
3
4
|
import {
|
|
4
5
|
type InputsObject,
|
|
@@ -12,84 +13,77 @@ import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentat
|
|
|
12
13
|
import { reuse } from '../internal/reuse'
|
|
13
14
|
import { makeDerivedStore, type Store } from '../internal/store'
|
|
14
15
|
import { readTracked } from '../internal/track'
|
|
15
|
-
import { type AnySource } from '../state/token'
|
|
16
|
+
import { type AnySource, type Source, type SourceCapture } from '../state/token'
|
|
16
17
|
|
|
17
|
-
// Re-exported so `Calc.InputsObject` / `Calc.InvalidateBy` keep their home here
|
|
18
|
-
// even though the reactive plumbing now lives in `internal/sources`.
|
|
19
18
|
export { type InputsObject, type InputStores, type InvalidateBy } from '../internal/sources'
|
|
20
19
|
|
|
21
|
-
// Boundary config object: optional fields are the framework's public, JSON-like
|
|
22
|
-
// definition surface (`ExternalApi` postfix exempts them from `no-optional-fields`).
|
|
23
20
|
export interface CalcOptionsExternalApi<Inputs extends ReadonlyArray<AnySource>> {
|
|
24
|
-
/**
|
|
25
|
-
* Custom invalidation: recompute only when the projected key changes by value
|
|
26
|
-
* equality (React-Query's `queryKey`). Omitted ⇒ recompute when any input
|
|
27
|
-
* value changes. Shared with `AsyncCalc`.
|
|
28
|
-
*/
|
|
29
21
|
readonly invalidateBy?: InvalidateBy<Inputs>
|
|
30
|
-
/**
|
|
31
|
-
* Structural sharing: reconcile each recomputed output against the previous
|
|
32
|
-
* one, substituting previous nodes wherever value equality holds — so
|
|
33
|
-
* identities only move where values moved and downstream memo boundaries
|
|
34
|
-
* (React subtrees) skip unchanged regions. Referentially transparent (the
|
|
35
|
-
* result is value-equal to the fresh output); costs one O(output) walk per
|
|
36
|
-
* recompute, so opt in for large projections.
|
|
37
|
-
*/
|
|
38
22
|
readonly reuse?: boolean
|
|
39
23
|
}
|
|
40
24
|
|
|
41
|
-
export interface
|
|
25
|
+
export interface CalcSchemaReflection {
|
|
26
|
+
readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CalcManifest<N extends string> extends Manifest {
|
|
42
30
|
readonly kind: 'Calc'
|
|
43
31
|
readonly name: N
|
|
44
|
-
readonly output:
|
|
32
|
+
readonly output: CalcSchemaReflection
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const CalcStoreTypeId: unique symbol = Symbol.for('reform/CalcStore')
|
|
36
|
+
export type CalcStoreTypeId = typeof CalcStoreTypeId
|
|
37
|
+
|
|
38
|
+
export interface CalcStore<N extends string, in out Out> extends Store<Out> {
|
|
39
|
+
readonly [CalcStoreTypeId]: N
|
|
45
40
|
}
|
|
46
41
|
|
|
47
|
-
export interface CalcClass<
|
|
48
|
-
extends
|
|
42
|
+
export interface CalcClass<N extends string, Inputs extends ReadonlyArray<AnySource>, Out>
|
|
43
|
+
extends EffectType<Out, never, CalcStore<N, Out>>, Source<N, Out, CalcStore<N, Out>> {
|
|
49
44
|
new (): {}
|
|
50
|
-
readonly manifest: CalcManifest<N
|
|
51
|
-
readonly store: Context.Tag<
|
|
52
|
-
/** The calc's name, so it doubles as a `Source` input to another calc. */
|
|
45
|
+
readonly manifest: CalcManifest<N>
|
|
46
|
+
readonly store: Context.Tag<CalcStore<N, Out>, Store<Out>>
|
|
53
47
|
readonly name: N
|
|
54
|
-
/** Marker data read by `Calc.live`. */
|
|
55
48
|
readonly inputs: Inputs
|
|
49
|
+
readonly capture: <Result>(visit: SourceCapture<Result>) => Result
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
export interface CalcConfig<
|
|
52
|
+
export interface CalcConfig<
|
|
53
|
+
Inputs extends ReadonlyArray<AnySource>,
|
|
54
|
+
OutputSchema extends Schema.Schema.AnyNoContext,
|
|
55
|
+
> {
|
|
59
56
|
readonly inputs: Inputs
|
|
60
|
-
readonly output:
|
|
57
|
+
readonly output: OutputSchema
|
|
61
58
|
}
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*/
|
|
69
|
-
export const make = <const N extends string, const Inputs extends ReadonlyArray<AnySource>, Out>(
|
|
60
|
+
export const make = <
|
|
61
|
+
const N extends string,
|
|
62
|
+
const Inputs extends ReadonlyArray<AnySource>,
|
|
63
|
+
OutputSchema extends Schema.Schema.AnyNoContext,
|
|
64
|
+
>(
|
|
70
65
|
name: N,
|
|
71
|
-
config: CalcConfig<Inputs,
|
|
72
|
-
): CalcClass<N, Inputs,
|
|
73
|
-
|
|
74
|
-
const
|
|
66
|
+
config: CalcConfig<Inputs, OutputSchema>,
|
|
67
|
+
): CalcClass<N, Inputs, Schema.Schema.Type<OutputSchema>> => {
|
|
68
|
+
type Out = Schema.Schema.Type<OutputSchema>
|
|
69
|
+
const store = Context.GenericTag<CalcStore<N, Out>, Store<Out>>(`reform/calc/${name}`)
|
|
70
|
+
const manifest: CalcManifest<N> = { kind: 'Calc', name, output: config.output }
|
|
75
71
|
const read = Effect.flatMap(store, readTracked)
|
|
76
|
-
|
|
72
|
+
const calc: CalcClass<N, Inputs, Out> = yieldableClass(read, {
|
|
73
|
+
manifest,
|
|
74
|
+
store,
|
|
75
|
+
name,
|
|
76
|
+
inputs: config.inputs,
|
|
77
|
+
capture: <Result>(visit: SourceCapture<Result>): Result => visit(calc),
|
|
78
|
+
})
|
|
79
|
+
return calc
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
/**
|
|
80
|
-
* Wire the pure projection for a calc. Recomputes only when its invalidation key
|
|
81
|
-
* changes (by default every input value; override with `invalidateBy`), memoized
|
|
82
|
-
* on that key; `yield* Calc` reads the memoized value. The derived store reuses
|
|
83
|
-
* the shared coalescing scheduler, so a recompute that yields an equal value
|
|
84
|
-
* wakes no subscriber.
|
|
85
|
-
*/
|
|
86
82
|
export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, Out>(
|
|
87
83
|
calc: CalcClass<N, Inputs, Out>,
|
|
88
84
|
compute: (inputs: InputsObject<Inputs>) => Out,
|
|
89
85
|
options: CalcOptionsExternalApi<Inputs> = {},
|
|
90
|
-
): Layer.Layer<
|
|
91
|
-
// Scoped so the source subscriptions are released when the layer's scope
|
|
92
|
-
// closes (each proof/test builds and disposes its own runtime).
|
|
86
|
+
): Layer.Layer<CalcStore<N, Out>, never, InputStores<Inputs>> =>
|
|
93
87
|
Layer.scoped(
|
|
94
88
|
calc.store,
|
|
95
89
|
Effect.gen(function* () {
|
|
@@ -97,8 +91,6 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
97
91
|
const instrumentation = yield* resolveInstrumentation
|
|
98
92
|
const sources = yield* wireSources(calc.inputs, options.invalidateBy)
|
|
99
93
|
|
|
100
|
-
// Memo cell: key and output captured together so reads are always
|
|
101
|
-
// consistent (no separate output that could lag the key it was computed for).
|
|
102
94
|
const memo = MutableRef.make<
|
|
103
95
|
{ readonly key: ReadonlyArray<unknown>; readonly output: Out } | undefined
|
|
104
96
|
>(undefined)
|
|
@@ -109,14 +101,8 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
109
101
|
if (prev !== undefined && sameKey(key, prev.key)) {
|
|
110
102
|
return prev.output
|
|
111
103
|
}
|
|
112
|
-
// Span covers the projection AND the optional reuse walk — both are the
|
|
113
|
-
// calc's real per-recompute cost. Memo hits return above, uncounted.
|
|
114
|
-
// (`manifest.name`, not `.name` — a user subclass shadows the static.)
|
|
115
104
|
const end = instrumentation.calcRecomputed(calc.manifest.name)
|
|
116
105
|
const fresh = compute(args)
|
|
117
|
-
// With `reuse`, a recompute that lands value-equal to the previous
|
|
118
|
-
// output returns the previous reference — the derived store's Equal
|
|
119
|
-
// gate then wakes no subscriber at all.
|
|
120
106
|
const output =
|
|
121
107
|
options.reuse === true && prev !== undefined ? reuse(prev.output, fresh) : fresh
|
|
122
108
|
end()
|
|
@@ -2,14 +2,8 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Context, Duration, Effect, Exit, Layer, Schema as S, Scope } from 'effect'
|
|
3
3
|
import { CalcFamily, State, StateGroup } from '../index'
|
|
4
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
5
|
const tick = Effect.sleep(Duration.millis(1))
|
|
11
6
|
|
|
12
|
-
// Shared fixture shape: a record of named counters; each member projects one.
|
|
13
7
|
const Board = S.Record({ key: S.String, value: S.Number })
|
|
14
8
|
|
|
15
9
|
const fixture = () => {
|
|
@@ -60,7 +54,6 @@ it.live('a member only notifies when its own slice of the projection moves', ()
|
|
|
60
54
|
wakes.b += 1
|
|
61
55
|
})
|
|
62
56
|
|
|
63
|
-
// Only `a` moves: b's member recomputes but its output is Equal — silent.
|
|
64
57
|
feed.set({ a: 2, b: 10 })
|
|
65
58
|
yield* tick
|
|
66
59
|
expect(wakes).toEqual({ a: 1, b: 0 })
|
|
@@ -71,19 +64,79 @@ it.live('a member only notifies when its own slice of the projection moves', ()
|
|
|
71
64
|
}).pipe(Effect.provide(TestLayer))
|
|
72
65
|
})
|
|
73
66
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
67
|
+
const reuseFixture = () => {
|
|
68
|
+
class Feed extends State.make('reuse-feed', Board) {}
|
|
69
|
+
class Revision extends State.make('reuse-revision', S.Number) {}
|
|
70
|
+
class Inputs extends StateGroup.make(Feed, Revision) {}
|
|
71
|
+
class Presentation extends CalcFamily.make('ReusePresentation', {
|
|
72
|
+
key: S.String,
|
|
73
|
+
inputs: [StateGroup.select(Inputs, 'reuse-feed'), StateGroup.select(Inputs, 'reuse-revision')],
|
|
74
|
+
output: S.Struct({ nested: S.Array(S.Struct({ value: S.Number })) }),
|
|
75
|
+
}) {}
|
|
76
|
+
const PresentationLive = CalcFamily.live(
|
|
77
|
+
Presentation,
|
|
78
|
+
(key) =>
|
|
79
|
+
({ 'reuse-feed': feed }) => ({
|
|
80
|
+
nested: [{ value: feed[key] ?? 0 }],
|
|
81
|
+
}),
|
|
82
|
+
{ reuse: true },
|
|
83
|
+
)
|
|
84
|
+
return { Inputs, Presentation, PresentationLive }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it.live('reuse preserves each member output when equal and replaces it when changed', () => {
|
|
88
|
+
const { Inputs, Presentation, PresentationLive } = reuseFixture()
|
|
89
|
+
const TestLayer = PresentationLive.pipe(
|
|
90
|
+
Layer.provideMerge(
|
|
91
|
+
StateGroup.live(Inputs, { 'reuse-feed': { a: 1, b: 1 }, 'reuse-revision': 0 }),
|
|
92
|
+
),
|
|
78
93
|
)
|
|
79
94
|
return Effect.gen(function* () {
|
|
80
|
-
yield*
|
|
81
|
-
yield*
|
|
82
|
-
yield*
|
|
83
|
-
|
|
95
|
+
const feed = yield* StateGroup.select(Inputs, 'reuse-feed').store
|
|
96
|
+
const revision = yield* StateGroup.select(Inputs, 'reuse-revision').store
|
|
97
|
+
const family = yield* Presentation.store
|
|
98
|
+
const initialA = family.at('a').get()
|
|
99
|
+
const initialB = family.at('b').get()
|
|
100
|
+
const wakes = { a: 0, b: 0 }
|
|
101
|
+
family.at('a').subscribe(() => {
|
|
102
|
+
wakes.a += 1
|
|
103
|
+
})
|
|
104
|
+
family.at('b').subscribe(() => {
|
|
105
|
+
wakes.b += 1
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(Object.is(initialA.nested, initialB.nested)).toBe(false)
|
|
109
|
+
revision.set(1)
|
|
110
|
+
yield* tick
|
|
111
|
+
expect(Object.is(family.at('a').get().nested, initialA.nested)).toBe(true)
|
|
112
|
+
expect(Object.is(family.at('b').get().nested, initialB.nested)).toBe(true)
|
|
113
|
+
expect(wakes).toEqual({ a: 0, b: 0 })
|
|
114
|
+
|
|
115
|
+
feed.set({ a: 2, b: 1 })
|
|
116
|
+
yield* tick
|
|
117
|
+
expect(family.at('a').get().nested).toEqual([{ value: 2 }])
|
|
118
|
+
expect(Object.is(family.at('a').get().nested, initialA.nested)).toBe(false)
|
|
119
|
+
expect(Object.is(family.at('b').get().nested, initialB.nested)).toBe(true)
|
|
120
|
+
expect(wakes).toEqual({ a: 1, b: 0 })
|
|
84
121
|
}).pipe(Effect.provide(TestLayer))
|
|
85
122
|
})
|
|
86
123
|
|
|
124
|
+
it.live(
|
|
125
|
+
'reading does not recompute while the shared inputs are unchanged (memoized per member)',
|
|
126
|
+
() => {
|
|
127
|
+
const { Inputs, Slice, SliceLive, runs } = fixture()
|
|
128
|
+
const TestLayer = SliceLive.pipe(
|
|
129
|
+
Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
|
|
130
|
+
)
|
|
131
|
+
return Effect.gen(function* () {
|
|
132
|
+
yield* CalcFamily.read(Slice, 'a')
|
|
133
|
+
yield* CalcFamily.read(Slice, 'a')
|
|
134
|
+
yield* CalcFamily.read(Slice, 'a')
|
|
135
|
+
expect(runs.get('a')).toBe(1)
|
|
136
|
+
}).pipe(Effect.provide(TestLayer))
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
87
140
|
it.live('invalidateBy: shared-input churn outside the key recomputes no member', () => {
|
|
88
141
|
class Feed extends State.make('feed', Board) {}
|
|
89
142
|
class Noise extends State.make('noise', S.Number) {}
|
|
@@ -96,10 +149,11 @@ it.live('invalidateBy: shared-input churn outside the key recomputes no member',
|
|
|
96
149
|
}) {}
|
|
97
150
|
const SliceLive = CalcFamily.live(
|
|
98
151
|
Slice,
|
|
99
|
-
(key) =>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
152
|
+
(key) =>
|
|
153
|
+
({ feed }) => {
|
|
154
|
+
runs.set(key, (runs.get(key) ?? 0) + 1)
|
|
155
|
+
return feed[key] ?? 0
|
|
156
|
+
},
|
|
103
157
|
{ invalidateBy: ({ feed }) => [feed] },
|
|
104
158
|
)
|
|
105
159
|
const TestLayer = SliceLive.pipe(
|
|
@@ -119,9 +173,7 @@ it.live('invalidateBy: shared-input churn outside the key recomputes no member',
|
|
|
119
173
|
|
|
120
174
|
it.live('forget releases the member and its upstream subscription', () => {
|
|
121
175
|
const { Inputs, Slice, SliceLive, runs } = fixture()
|
|
122
|
-
const TestLayer = SliceLive.pipe(
|
|
123
|
-
Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
|
|
124
|
-
)
|
|
176
|
+
const TestLayer = SliceLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })))
|
|
125
177
|
return Effect.gen(function* () {
|
|
126
178
|
const feed = yield* StateGroup.select(Inputs, 'feed').store
|
|
127
179
|
const family = yield* Slice.store
|
|
@@ -130,13 +182,11 @@ it.live('forget releases the member and its upstream subscription', () => {
|
|
|
130
182
|
|
|
131
183
|
family.forget('a')
|
|
132
184
|
expect(family.size()).toBe(0)
|
|
133
|
-
// The dropped member no longer recomputes on upstream churn.
|
|
134
185
|
const before = runs.get('a') ?? 0
|
|
135
186
|
feed.set({ a: 2 })
|
|
136
187
|
yield* tick
|
|
137
188
|
expect(runs.get('a') ?? 0).toBe(before)
|
|
138
189
|
|
|
139
|
-
// Re-`at`-ing allocates a fresh member over the current snapshot.
|
|
140
190
|
expect(family.at('a').get()).toBe(2)
|
|
141
191
|
}).pipe(Effect.provide(TestLayer))
|
|
142
192
|
})
|
|
@@ -152,15 +202,14 @@ it.live('evictWhenUnused: an idle member is dropped on the next microtask and un
|
|
|
152
202
|
}) {}
|
|
153
203
|
const SliceLive = CalcFamily.live(
|
|
154
204
|
Slice,
|
|
155
|
-
(key) =>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
205
|
+
(key) =>
|
|
206
|
+
({ feed }) => {
|
|
207
|
+
runs.set(key, (runs.get(key) ?? 0) + 1)
|
|
208
|
+
return feed[key] ?? 0
|
|
209
|
+
},
|
|
159
210
|
{ evictWhenUnused: true },
|
|
160
211
|
)
|
|
161
|
-
const TestLayer = SliceLive.pipe(
|
|
162
|
-
Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
|
|
163
|
-
)
|
|
212
|
+
const TestLayer = SliceLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })))
|
|
164
213
|
return Effect.gen(function* () {
|
|
165
214
|
const feed = yield* StateGroup.select(Inputs, 'feed').store
|
|
166
215
|
const family = yield* Slice.store
|
|
@@ -181,9 +230,6 @@ it.live('the layer finalizer clears every member (no live subscriptions leak)',
|
|
|
181
230
|
const { Inputs, Slice, SliceLive, runs } = fixture()
|
|
182
231
|
return Effect.scoped(
|
|
183
232
|
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
233
|
const storesCtx = yield* Layer.build(StateGroup.live(Inputs, { feed: { a: 1, b: 2 } }))
|
|
188
234
|
const feed = Context.get(storesCtx, StateGroup.select(Inputs, 'feed').store)
|
|
189
235
|
const scope = yield* Scope.make()
|