@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.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -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) // 5 + (5*2)
90
+ expect(derived.get()).toBe(15)
99
91
  source.set(3)
100
92
  yield* tick
101
- expect(derived.get()).toBe(9) // 3 + (3*2)
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) // (0+1) + (0+10)
125
+ expect(c.get()).toBe(11)
137
126
 
138
127
  source.set(5)
139
128
  yield* tick
140
- expect(c.get()).toBe(21) // (5+1) + (5+10)
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
- Split,
236
- ({ a, b }) => ({ left: { value: a }, right: { value: b } }),
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 CalcManifest<N extends string, Out> extends Manifest {
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: Schema.Schema<Out, any>
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<out N extends string, out Inputs extends ReadonlyArray<AnySource>, in out Out>
48
- extends Effect.Effect<Out, never, Store<Out>> {
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, Out>
51
- readonly store: Context.Tag<Store<Out>, Store<Out>>
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<Inputs extends ReadonlyArray<AnySource>, Out> {
52
+ export interface CalcConfig<
53
+ Inputs extends ReadonlyArray<AnySource>,
54
+ OutputSchema extends Schema.Schema.AnyNoContext,
55
+ > {
59
56
  readonly inputs: Inputs
60
- readonly output: Schema.Schema<Out, any>
57
+ readonly output: OutputSchema
61
58
  }
62
59
 
63
- /**
64
- * A derived, memoized value over explicit inputs (state members, other calcs, or
65
- * async calcs). The *definition* fixes the inputs and the output schema; the
66
- * *implementation* is the pure projection. Recomputes only when an input changes;
67
- * `yield* Calc` reads the memoized value.
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, Out>,
72
- ): CalcClass<N, Inputs, Out> => {
73
- const store = Context.GenericTag<Store<Out>, Store<Out>>(`reform/calc/${name}`)
74
- const manifest: CalcManifest<N, Out> = { kind: 'Calc', name, output: config.output }
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
- return yieldableClass(read, { manifest, store, name, inputs: config.inputs })
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<Store<Out>, never, InputStores<Inputs>> =>
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
- 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 } })),
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* CalcFamily.read(Slice, 'a')
81
- yield* CalcFamily.read(Slice, 'a')
82
- yield* CalcFamily.read(Slice, 'a')
83
- expect(runs.get('a')).toBe(1)
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) => ({ feed }) => {
100
- runs.set(key, (runs.get(key) ?? 0) + 1)
101
- return feed[key] ?? 0
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) => ({ feed }) => {
156
- runs.set(key, (runs.get(key) ?? 0) + 1)
157
- return feed[key] ?? 0
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()