@playfast/reform 1.1.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 (85) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +62 -16
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +516 -18
  6. package/src/calc/asyncCalc.test.ts +207 -175
  7. package/src/calc/asyncCalc.ts +168 -39
  8. package/src/calc/calc.test.ts +3 -5
  9. package/src/calc/calc.ts +44 -18
  10. package/src/calc/calcFamily.test.ts +80 -22
  11. package/src/calc/calcFamily.ts +56 -25
  12. package/src/calc/compose.ts +1 -14
  13. package/src/channel/channel.ts +128 -11
  14. package/src/compose/composition.test.ts +19 -0
  15. package/src/compose/composition.ts +346 -62
  16. package/src/compose/props.ts +13 -6
  17. package/src/compose/provide.ts +187 -46
  18. package/src/compose/slot.ts +156 -19
  19. package/src/compose/structure.test.ts +6 -3
  20. package/src/compose/structure.ts +106 -13
  21. package/src/compose/ui.test.ts +19 -3
  22. package/src/compose/ui.ts +147 -54
  23. package/src/compose/ui.typecheck.ts +40 -4
  24. package/src/definition/definition.ts +22 -9
  25. package/src/event/event.fromSource.test.ts +172 -0
  26. package/src/event/event.test.ts +10 -0
  27. package/src/event/event.ts +102 -18
  28. package/src/feature/feature.mount.test.ts +123 -56
  29. package/src/feature/feature.test.ts +67 -63
  30. package/src/feature/feature.ts +1317 -197
  31. package/src/feature/feature.typecheck.ts +253 -61
  32. package/src/graph/closure.ts +403 -0
  33. package/src/index.ts +171 -245
  34. package/src/internal/bucketCache.ts +39 -0
  35. package/src/internal/capture.ts +9 -4
  36. package/src/internal/env.ts +1 -3
  37. package/src/internal/errors.ts +21 -10
  38. package/src/internal/queryDriver.ts +120 -15
  39. package/src/internal/queryDriverStore.ts +8 -5
  40. package/src/internal/queryDriverTypes.ts +3 -1
  41. package/src/internal/reuse.test.ts +10 -3
  42. package/src/internal/reuse.ts +5 -2
  43. package/src/internal/scheduler.ts +4 -1
  44. package/src/internal/sources.ts +25 -11
  45. package/src/internal/track.ts +3 -2
  46. package/src/internal.ts +222 -0
  47. package/src/namespace/namespace.ts +46 -25
  48. package/src/procedure/procedure.ts +38 -11
  49. package/src/reducer/reducer.ts +78 -34
  50. package/src/remote/remoteState.test.ts +515 -339
  51. package/src/remote/remoteState.ts +572 -107
  52. package/src/remote/remoteState.typecheck.ts +47 -23
  53. package/src/runtime/appRuntime.activation.test.ts +100 -0
  54. package/src/runtime/appRuntime.test.ts +157 -105
  55. package/src/runtime/appRuntime.ts +394 -33
  56. package/src/runtime/eventBudget.test.ts +1 -4
  57. package/src/runtime/eventBudget.ts +9 -8
  58. package/src/runtime/hardening.test.ts +4 -9
  59. package/src/runtime/instrumentation.test.ts +174 -192
  60. package/src/runtime/instrumentation.ts +6 -11
  61. package/src/runtime/loop.test.ts +36 -0
  62. package/src/runtime/loop.ts +69 -40
  63. package/src/scene/featureScene.test.ts +4 -2
  64. package/src/scene/scene.ts +234 -64
  65. package/src/scene/seedScene.test.ts +69 -86
  66. package/src/state/state.nominal.typecheck.ts +68 -0
  67. package/src/state/state.test.ts +17 -0
  68. package/src/state/state.ts +79 -26
  69. package/src/state/stateFamily.test.ts +14 -0
  70. package/src/state/stateFamily.ts +55 -22
  71. package/src/state/stateGroup.ts +53 -17
  72. package/src/state/token.ts +40 -10
  73. package/src/synced/syncedStore.ts +46 -23
  74. package/src/testkit/flight.testkit.ts +75 -0
  75. package/src/wire/tree.test.ts +8 -4
  76. package/src/wire/triggers.test.ts +6 -2
  77. package/src/wire/triggers.ts +14 -10
  78. package/src/calc/asyncCalcDefinitions.ts +0 -48
  79. package/src/channel/procedureRegistry.ts +0 -90
  80. package/src/feature/featureBinding.ts +0 -48
  81. package/src/internal/ctx.ts +0 -9
  82. package/src/remote/remoteStateDefinition.ts +0 -135
  83. package/src/remote/remoteStateLayers.ts +0 -118
  84. package/src/remote/remoteStateLiveTypes.ts +0 -59
  85. package/src/remote/remoteStateSend.ts +0 -64
@@ -1,16 +1,13 @@
1
- import { expect, it } from "@effect/vitest";
2
- import { Duration, Effect, Layer, Schema as S } from "effect";
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, Schema as S } from 'effect'
3
3
  import {
4
4
  AsyncCalc,
5
5
  Calc,
6
6
  Channel,
7
7
  Composition,
8
- CurrentInstrumentation,
9
8
  Engine,
10
9
  Event,
11
- type Instrumentation,
12
10
  mount,
13
- noopInstrumentation,
14
11
  type Priority,
15
12
  Procedure,
16
13
  profiledScene,
@@ -23,64 +20,67 @@ import {
23
20
  StateGroup,
24
21
  Ui,
25
22
  ui,
26
- } from "../index";
23
+ } from '../index'
24
+ import {
25
+ CurrentInstrumentation,
26
+ type Instrumentation,
27
+ noopInstrumentation,
28
+ } from './instrumentation'
27
29
 
28
- const tick = (ms = 20) => Effect.sleep(Duration.millis(ms));
30
+ const tick = (ms = 20) => Effect.sleep(Duration.millis(ms))
29
31
 
30
32
  const makeRecorder = () => {
31
- const events: Array<{ readonly tag: string; readonly priority: Priority }> =
32
- [];
33
- const reducers: Array<{ readonly name: string; readonly eventTag: string }> =
34
- [];
35
- const states: Array<string> = [];
36
- const calcs: Array<string> = [];
37
- const queries: Array<string> = [];
33
+ const events: Array<{ readonly tag: string; readonly priority: Priority }> = []
34
+ const reducers: Array<{ readonly name: string; readonly eventTag: string }> = []
35
+ const states: Array<string> = []
36
+ const calcs: Array<string> = []
37
+ const queries: Array<string> = []
38
38
  const procedures: Array<{
39
- readonly name: string;
40
- readonly channel: string;
41
- readonly eventTag: string;
42
- }> = [];
43
- const frames: Array<number> = [];
44
- const ends = { reducer: 0, calc: 0, query: 0, procedure: 0, frame: 0 };
39
+ readonly name: string
40
+ readonly channel: string
41
+ readonly eventTag: string
42
+ }> = []
43
+ const frames: Array<number> = []
44
+ const ends = { reducer: 0, calc: 0, query: 0, procedure: 0, frame: 0 }
45
45
  const instrumentation: Instrumentation = {
46
46
  eventDispatched: (tag, priority) => {
47
- events.push({ tag, priority });
47
+ events.push({ tag, priority })
48
48
  },
49
49
  reducerRun: (name, eventTag) => {
50
- reducers.push({ name, eventTag });
50
+ reducers.push({ name, eventTag })
51
51
  return () => {
52
- ends.reducer += 1;
53
- };
52
+ ends.reducer += 1
53
+ }
54
54
  },
55
55
  stateUpdated: (name) => {
56
- states.push(name);
56
+ states.push(name)
57
57
  },
58
58
  calcRecomputed: (name) => {
59
- calcs.push(name);
59
+ calcs.push(name)
60
60
  return () => {
61
- ends.calc += 1;
62
- };
61
+ ends.calc += 1
62
+ }
63
63
  },
64
64
  queryRun: (name) => {
65
- queries.push(name);
65
+ queries.push(name)
66
66
  return () => {
67
- ends.query += 1;
68
- };
67
+ ends.query += 1
68
+ }
69
69
  },
70
70
  procedureRun: (name, channel, eventTag) => {
71
- procedures.push({ name, channel, eventTag });
71
+ procedures.push({ name, channel, eventTag })
72
72
  return () => {
73
- ends.procedure += 1;
74
- };
73
+ ends.procedure += 1
74
+ }
75
75
  },
76
76
  uiRendered: () => () => {},
77
77
  frame: (eventCount) => {
78
- frames.push(eventCount);
78
+ frames.push(eventCount)
79
79
  return () => {
80
- ends.frame += 1;
81
- };
80
+ ends.frame += 1
81
+ }
82
82
  },
83
- };
83
+ }
84
84
  return {
85
85
  instrumentation,
86
86
  events,
@@ -91,186 +91,175 @@ const makeRecorder = () => {
91
91
  procedures,
92
92
  frames,
93
93
  ends,
94
- };
95
- };
94
+ }
95
+ }
96
96
 
97
- it.live(
98
- "the engine reports frames, events, reducer spans, and state updates",
99
- () => {
100
- const recorder = makeRecorder();
101
- class Counter extends State.make("instr-counter", S.Number) {}
102
- class Bumped extends Event.make(
103
- "InstrBumped",
104
- S.Struct({ to: S.Number }),
105
- ) {}
106
- class Fold extends Reducer.make("InstrFold", {
107
- states: [Counter],
108
- events: [Bumped],
109
- }) {}
110
- const FoldLive = Reducer.live(Fold, (_, event) => event.to);
97
+ it.live('the engine reports frames, events, reducer spans, and state updates', () => {
98
+ const recorder = makeRecorder()
99
+ class Counter extends State.make('instr-counter', S.Number) {}
100
+ class Bumped extends Event.make('InstrBumped', S.Struct({ to: S.Number })) {}
101
+ class Fold extends Reducer.make('InstrFold', {
102
+ states: [Counter],
103
+ events: [Bumped],
104
+ }) {}
105
+ const FoldLive = Reducer.live(Fold, (_, event) => event.to)
111
106
 
112
- const TestLayer = FoldLive.pipe(
113
- Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
114
- Layer.locally(CurrentInstrumentation, recorder.instrumentation),
115
- );
107
+ const TestLayer = FoldLive.pipe(
108
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
109
+ Layer.locally(CurrentInstrumentation, recorder.instrumentation),
110
+ )
116
111
 
117
- return Effect.gen(function* () {
118
- yield* publish("High", Event.construct(Bumped, { to: 1 }));
119
- yield* publish("Normal", Event.construct(Bumped, { to: 2 }));
120
- yield* tick();
112
+ return Effect.gen(function* () {
113
+ yield* publish('High', Event.construct(Bumped, { to: 1 }))
114
+ yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
115
+ yield* tick()
121
116
 
122
- expect(recorder.frames).toEqual([2]);
123
- expect(recorder.ends.frame).toBe(1);
124
- expect(recorder.events).toEqual([
125
- { tag: "InstrBumped", priority: "High" },
126
- { tag: "InstrBumped", priority: "Normal" },
127
- ]);
128
- expect(recorder.reducers).toEqual([
129
- { name: "InstrFold", eventTag: "InstrBumped" },
130
- { name: "InstrFold", eventTag: "InstrBumped" },
131
- ]);
132
- expect(recorder.ends.reducer).toBe(2);
133
- expect(recorder.states).toEqual(["instr-counter", "instr-counter"]);
134
- }).pipe(Effect.provide(TestLayer));
135
- },
136
- );
117
+ expect(recorder.frames).toEqual([2])
118
+ expect(recorder.ends.frame).toBe(1)
119
+ expect(recorder.events).toEqual([
120
+ { tag: 'InstrBumped', priority: 'High' },
121
+ { tag: 'InstrBumped', priority: 'Normal' },
122
+ ])
123
+ expect(recorder.reducers).toEqual([
124
+ { name: 'InstrFold', eventTag: 'InstrBumped' },
125
+ { name: 'InstrFold', eventTag: 'InstrBumped' },
126
+ ])
127
+ expect(recorder.ends.reducer).toBe(2)
128
+ expect(recorder.states).toEqual(['instr-counter', 'instr-counter'])
129
+ }).pipe(Effect.provide(TestLayer))
130
+ })
137
131
 
138
- it.live("an equal-value write is not reported as a state update", () => {
139
- const recorder = makeRecorder();
140
- class Cell extends State.make("instr-cell", S.Number) {}
141
- class Wrote extends Event.make("InstrWrote", S.Struct({ to: S.Number })) {}
142
- class Write extends Reducer.make("InstrWrite", {
132
+ it.live('an equal-value write is not reported as a state update', () => {
133
+ const recorder = makeRecorder()
134
+ class Cell extends State.make('instr-cell', S.Number) {}
135
+ class Wrote extends Event.make('InstrWrote', S.Struct({ to: S.Number })) {}
136
+ class Write extends Reducer.make('InstrWrite', {
143
137
  states: [Cell],
144
138
  events: [Wrote],
145
139
  }) {}
146
- const WriteLive = Reducer.live(Write, (_, event) => event.to);
140
+ const WriteLive = Reducer.live(Write, (_, event) => event.to)
147
141
 
148
142
  const TestLayer = WriteLive.pipe(
149
143
  Layer.provideMerge(Layer.mergeAll(State.live(Cell, 7), Engine)),
150
144
  Layer.locally(CurrentInstrumentation, recorder.instrumentation),
151
- );
145
+ )
152
146
 
153
147
  return Effect.gen(function* () {
154
- yield* publish("High", Event.construct(Wrote, { to: 7 }));
155
- yield* tick();
156
- expect(recorder.reducers.length).toBe(1);
157
- expect(recorder.states).toEqual([]);
158
- }).pipe(Effect.provide(TestLayer));
159
- });
148
+ yield* publish('High', Event.construct(Wrote, { to: 7 }))
149
+ yield* tick()
150
+ expect(recorder.reducers.length).toBe(1)
151
+ expect(recorder.states).toEqual([])
152
+ }).pipe(Effect.provide(TestLayer))
153
+ })
160
154
 
161
- it.live("calc recomputes are spanned; memo hits are not counted", () => {
162
- const recorder = makeRecorder();
163
- class Count extends State.make("instr-calc-count", S.Number) {}
155
+ it.live('calc recomputes are spanned; memo hits are not counted', () => {
156
+ const recorder = makeRecorder()
157
+ class Count extends State.make('instr-calc-count', S.Number) {}
164
158
  class Inputs extends StateGroup.make(Count) {}
165
- class Doubled extends Calc.make("InstrDoubled", {
166
- inputs: [StateGroup.select(Inputs, "instr-calc-count")],
159
+ class Doubled extends Calc.make('InstrDoubled', {
160
+ inputs: [StateGroup.select(Inputs, 'instr-calc-count')],
167
161
  output: S.Number,
168
162
  }) {}
169
- const DoubledLive = Calc.live(
170
- Doubled,
171
- (inputs) => inputs["instr-calc-count"] * 2,
172
- );
163
+ const DoubledLive = Calc.live(Doubled, (inputs) => inputs['instr-calc-count'] * 2)
173
164
 
174
165
  const TestLayer = DoubledLive.pipe(
175
- Layer.provideMerge(StateGroup.live(Inputs, { "instr-calc-count": 5 })),
166
+ Layer.provideMerge(StateGroup.live(Inputs, { 'instr-calc-count': 5 })),
176
167
  Layer.locally(CurrentInstrumentation, recorder.instrumentation),
177
- );
168
+ )
178
169
 
179
170
  return Effect.gen(function* () {
180
- const derived = yield* Doubled.store;
181
- const builds = recorder.calcs.length;
182
- expect(derived.get()).toBe(10);
183
- expect(derived.get()).toBe(10);
184
- expect(recorder.calcs.length).toBe(builds);
171
+ const derived = yield* Doubled.store
172
+ const builds = recorder.calcs.length
173
+ expect(derived.get()).toBe(10)
174
+ expect(derived.get()).toBe(10)
175
+ expect(recorder.calcs.length).toBe(builds)
185
176
 
186
- const source = yield* StateGroup.select(Inputs, "instr-calc-count").store;
187
- source.set(6);
188
- yield* tick();
189
- expect(derived.get()).toBe(12);
190
- expect(recorder.calcs.length).toBe(builds + 1);
191
- expect(recorder.calcs.every((name) => name === "InstrDoubled")).toBe(true);
192
- expect(recorder.ends.calc).toBe(recorder.calcs.length);
193
- expect(recorder.states).toContain("InstrDoubled");
194
- }).pipe(Effect.provide(TestLayer));
195
- });
177
+ const source = yield* StateGroup.select(Inputs, 'instr-calc-count').store
178
+ source.set(6)
179
+ yield* tick()
180
+ expect(derived.get()).toBe(12)
181
+ expect(recorder.calcs.length).toBe(builds + 1)
182
+ expect(recorder.calcs.every((name) => name === 'InstrDoubled')).toBe(true)
183
+ expect(recorder.ends.calc).toBe(recorder.calcs.length)
184
+ expect(recorder.states).toContain('InstrDoubled')
185
+ }).pipe(Effect.provide(TestLayer))
186
+ })
196
187
 
197
- it.live("async calc query runs are spanned start-to-settle", () => {
198
- const recorder = makeRecorder();
199
- class Count extends State.make("instr-query-count", S.Number) {}
188
+ it.live('async calc query runs are spanned start-to-settle', () => {
189
+ const recorder = makeRecorder()
190
+ class Count extends State.make('instr-query-count', S.Number) {}
200
191
  class Inputs extends StateGroup.make(Count) {}
201
- class Fetched extends AsyncCalc.make("InstrFetched", {
202
- inputs: [StateGroup.select(Inputs, "instr-query-count")],
192
+ class Fetched extends AsyncCalc.make('InstrFetched', {
193
+ inputs: [StateGroup.select(Inputs, 'instr-query-count')],
203
194
  output: S.Number,
204
195
  alwaysOn: true,
205
196
  }) {}
206
197
  const FetchedLive = AsyncCalc.live(Fetched, {
207
198
  query: (inputs) =>
208
- Effect.succeed(inputs["instr-query-count"] * 2).pipe(
209
- Effect.delay(Duration.millis(5)),
210
- ),
211
- });
199
+ Effect.succeed(inputs['instr-query-count'] * 2).pipe(Effect.delay(Duration.millis(5))),
200
+ })
212
201
 
213
202
  const TestLayer = FetchedLive.pipe(
214
- Layer.provideMerge(StateGroup.live(Inputs, { "instr-query-count": 3 })),
203
+ Layer.provideMerge(StateGroup.live(Inputs, { 'instr-query-count': 3 })),
215
204
  Layer.locally(CurrentInstrumentation, recorder.instrumentation),
216
- );
205
+ )
217
206
 
218
207
  return Effect.gen(function* () {
219
- const store = yield* Fetched.store;
220
- yield* tick(40);
221
- expect(store.get()._tag).toBe("Success");
222
- expect(recorder.queries).toEqual(["InstrFetched"]);
223
- expect(recorder.ends.query).toBe(1);
224
- }).pipe(Effect.provide(TestLayer));
225
- });
208
+ const store = yield* Fetched.store
209
+ yield* tick(40)
210
+ expect(store.get()._tag).toBe('Success')
211
+ expect(recorder.queries).toEqual(['InstrFetched'])
212
+ expect(recorder.ends.query).toBe(1)
213
+ }).pipe(Effect.provide(TestLayer))
214
+ })
226
215
 
227
- it.live("procedure runs are spanned with their channel and trigger tag", () => {
228
- const recorder = makeRecorder();
229
- class Kicked extends Event.make("InstrKicked", S.Struct({})) {}
230
- class Lane extends Channel.make("InstrLane", { policy: { _tag: "merge" } }) {}
231
- class Work extends Procedure.make("InstrWork", {
216
+ it.live('procedure runs are spanned with their channel and trigger tag', () => {
217
+ const recorder = makeRecorder()
218
+ class Kicked extends Event.make('InstrKicked', S.Struct({})) {}
219
+ class Lane extends Channel.make('InstrLane', { policy: { _tag: 'merge' } }) {}
220
+ class Work extends Procedure.make('InstrWork', {
232
221
  events: [Kicked],
233
222
  channel: Lane,
234
223
  }) {}
235
224
  const WorkLive = Procedure.live(Work, function* () {
236
- yield* tick(5);
237
- });
225
+ yield* tick(5)
226
+ })
238
227
 
239
228
  const TestLayer = Layer.mergeAll(Channel.live(Lane), WorkLive).pipe(
240
229
  Layer.provideMerge(Engine),
241
230
  Layer.locally(CurrentInstrumentation, recorder.instrumentation),
242
- );
231
+ )
243
232
 
244
233
  return Effect.gen(function* () {
245
- yield* publish("Normal", Event.construct(Kicked, {}));
246
- yield* tick(40);
234
+ yield* publish('Normal', Event.construct(Kicked, {}))
235
+ yield* tick(40)
247
236
  expect(recorder.procedures).toEqual([
248
- { name: "InstrWork", channel: "InstrLane", eventTag: "InstrKicked" },
249
- ]);
250
- expect(recorder.ends.procedure).toBe(1);
251
- }).pipe(Effect.provide(TestLayer));
252
- });
237
+ { name: 'InstrWork', channel: 'InstrLane', eventTag: 'InstrKicked' },
238
+ ])
239
+ expect(recorder.ends.procedure).toBe(1)
240
+ }).pipe(Effect.provide(TestLayer))
241
+ })
253
242
 
254
- class SceneCount extends State.make("instr-scene-count", S.Number) {}
243
+ class SceneCount extends State.make('instr-scene-count', S.Number) {}
255
244
  class SceneStates extends StateGroup.make(SceneCount) {}
256
- class SceneBumped extends Event.make("InstrSceneBumped", S.Struct({})) {}
257
- class SceneBump extends Reducer.make("InstrSceneBump", {
245
+ class SceneBumped extends Event.make('InstrSceneBumped', S.Struct({})) {}
246
+ class SceneBump extends Reducer.make('InstrSceneBump', {
258
247
  states: [SceneCount],
259
248
  events: [SceneBumped],
260
249
  }) {}
261
- const SceneBumpLive = Reducer.live(SceneBump, (n) => n + 1);
250
+ const SceneBumpLive = Reducer.live(SceneBump, (n) => n + 1)
262
251
 
263
- class SceneUi extends ui("InstrScene")<{ props: { count: number } }>() {}
264
- class SceneRoot extends Composition.make("InstrScene", {
265
- title: "InstrScene",
252
+ class SceneUi extends ui('InstrScene')<{ props: { count: number } }>() {}
253
+ class SceneRoot extends Composition.make('InstrScene', {
254
+ title: 'InstrScene',
266
255
  states: [SceneStates],
267
256
  events: [SceneBumped],
268
257
  ui: SceneUi,
269
- }) {}
258
+ })<SceneRoot>() {}
270
259
  const SceneRootLive = Composition.live(SceneRoot, function* () {
271
- const count = yield* StateGroup.select(SceneStates, "instr-scene-count");
272
- return mount({ props: { count }, slots: {} });
273
- });
260
+ const count = yield* StateGroup.select(SceneStates, 'instr-scene-count')
261
+ return mount({ props: { count }, slots: {} })
262
+ })
274
263
 
275
264
  const SceneApp = SceneRootLive.pipe(
276
265
  Layer.provideMerge(
@@ -282,43 +271,36 @@ const SceneApp = SceneRootLive.pipe(
282
271
  Layer.provideMerge(
283
272
  Layer.mergeAll(SceneBumpLive).pipe(
284
273
  Layer.provideMerge(
285
- Layer.mergeAll(
286
- Engine,
287
- StateGroup.live(SceneStates, { "instr-scene-count": 0 }),
288
- ),
274
+ Layer.mergeAll(Engine, StateGroup.live(SceneStates, { 'instr-scene-count': 0 })),
289
275
  ),
290
276
  ),
291
277
  ),
292
- );
293
- const InstrScene = scene(SceneRoot, { provide: [SceneApp] });
278
+ )
279
+ const InstrScene = scene(SceneRoot, { provide: [SceneApp] })
294
280
 
295
281
  const runScene = (target: typeof InstrScene) => {
296
- const layer = target.provide.reduce((a, b) => Layer.merge(a, b));
282
+ const layer = target.provide.reduce((a, b) => Layer.merge(a, b))
297
283
  const drive = Effect.gen(function* () {
298
- yield* publish("High", Event.construct(SceneBumped, {}));
299
- yield* tick();
300
- });
301
- return { layer, drive };
302
- };
284
+ yield* publish('High', Event.construct(SceneBumped, {}))
285
+ yield* tick()
286
+ })
287
+ return { layer, drive }
288
+ }
303
289
 
304
- it.scopedLive(
305
- "profiledScene reaches nested layers and carries the handle",
306
- () =>
307
- Effect.gen(function* () {
308
- const recorder = makeRecorder();
309
- const profiled = profiledScene(InstrScene, recorder.instrumentation);
310
- expect(sceneInstrumentation(profiled)).toBe(recorder.instrumentation);
290
+ it.scopedLive('profiledScene reaches nested layers and carries the handle', () =>
291
+ Effect.gen(function* () {
292
+ const recorder = makeRecorder()
293
+ const profiled = profiledScene(InstrScene, recorder.instrumentation)
294
+ expect(sceneInstrumentation(profiled)).toBe(recorder.instrumentation)
311
295
 
312
- const profiledHarness = runScene(profiled);
313
- yield* profiledHarness.drive.pipe(Effect.provide(profiledHarness.layer));
314
- expect(recorder.reducers).toEqual([
315
- { name: "InstrSceneBump", eventTag: "InstrSceneBumped" },
316
- ]);
317
- expect(recorder.states).toEqual(["instr-scene-count"]);
318
- const before = recorder.reducers.length;
319
- expect(sceneInstrumentation(InstrScene)).toBe(noopInstrumentation);
320
- const original = runScene(InstrScene);
321
- yield* original.drive.pipe(Effect.provide(original.layer));
322
- expect(recorder.reducers.length).toBe(before);
323
- }),
324
- );
296
+ const profiledHarness = runScene(profiled)
297
+ yield* profiledHarness.drive.pipe(Effect.provide(profiledHarness.layer))
298
+ expect(recorder.reducers).toEqual([{ name: 'InstrSceneBump', eventTag: 'InstrSceneBumped' }])
299
+ expect(recorder.states).toEqual(['instr-scene-count'])
300
+ const before = recorder.reducers.length
301
+ expect(sceneInstrumentation(InstrScene)).toBe(noopInstrumentation)
302
+ const original = runScene(InstrScene)
303
+ yield* original.drive.pipe(Effect.provide(original.layer))
304
+ expect(recorder.reducers.length).toBe(before)
305
+ }),
306
+ )
@@ -27,18 +27,13 @@ export const noopInstrumentation: Instrumentation = {
27
27
  frame: () => noopSpanEnd,
28
28
  }
29
29
 
30
- export const CurrentInstrumentation: FiberRef.FiberRef<Instrumentation> =
31
- GlobalValue.globalValue(Symbol.for('reform/CurrentInstrumentation'), () =>
32
- FiberRef.unsafeMake<Instrumentation>(noopInstrumentation),
33
- )
30
+ export const CurrentInstrumentation: FiberRef.FiberRef<Instrumentation> = GlobalValue.globalValue(
31
+ Symbol.for('reform/CurrentInstrumentation'),
32
+ () => FiberRef.unsafeMake<Instrumentation>(noopInstrumentation),
33
+ )
34
34
 
35
35
  export const resolveInstrumentation: Effect.Effect<Instrumentation> =
36
36
  FiberRef.get(CurrentInstrumentation)
37
37
 
38
- export const stateUpdateHook = (
39
- instrumentation: Instrumentation,
40
- name: string,
41
- ): (() => void) =>
42
- instrumentation === noopInstrumentation
43
- ? noopSpanEnd
44
- : () => instrumentation.stateUpdated(name)
38
+ export const stateUpdateHook = (instrumentation: Instrumentation, name: string): (() => void) =>
39
+ instrumentation === noopInstrumentation ? noopSpanEnd : () => instrumentation.stateUpdated(name)
@@ -159,3 +159,39 @@ it.effect('a latest channel cancels the in-flight run when a new event arrives',
159
159
  expect(counts.completed).toBe(1)
160
160
  }).pipe(Effect.provide(TestLayer))
161
161
  })
162
+
163
+ it.live('a fold returning the previous reference keeps the exact store value', () => {
164
+ const Viewer = S.Union(
165
+ S.TaggedStruct('Closed', {}),
166
+ S.TaggedStruct('Open', { revision: S.Number }),
167
+ )
168
+ class ViewerState extends State.make('viewer', Viewer) {}
169
+ class Poked extends Event.make('Poked', S.Struct({})) {}
170
+ class PokeReducer extends Reducer.make('PokeReducer', {
171
+ states: [ViewerState],
172
+ events: [Poked],
173
+ }) {}
174
+ // Closed is a fixpoint: the fold hands back the previous reference untouched.
175
+ const PokeReducerLive = Reducer.live(PokeReducer, (viewer) =>
176
+ viewer._tag === 'Open' ? { ...viewer, revision: viewer.revision + 1 } : viewer,
177
+ )
178
+
179
+ const TestLayer = PokeReducerLive.pipe(
180
+ Layer.provideMerge(Layer.mergeAll(State.live(ViewerState, { _tag: 'Closed' }), Engine)),
181
+ )
182
+
183
+ return Effect.gen(function* () {
184
+ const store = yield* ViewerState.store
185
+ const before = store.get()
186
+ const notifications = { n: 0 }
187
+ store.subscribe(() => {
188
+ notifications.n += 1
189
+ })
190
+ yield* publish('Normal', Event.construct(Poked, {}))
191
+ yield* Effect.sleep(Duration.millis(20))
192
+ // Reference identity is the repaint signal: an unchanged fold must not be
193
+ // re-decoded into a fresh, structurally-equal value that wakes subscribers.
194
+ expect(store.get()).toBe(before)
195
+ expect(notifications.n).toBe(0)
196
+ }).pipe(Effect.provide(TestLayer))
197
+ })