@playfast/reform 1.0.1 → 1.1.1
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 +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +6 -46
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +6 -48
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +72 -174
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -229
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +42 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- 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 +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +40 -83
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +213 -192
- package/src/runtime/instrumentation.ts +0 -49
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +25 -55
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +32 -59
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +7 -26
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +9 -52
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { expect, it } from
|
|
2
|
-
import {
|
|
3
|
-
import { Duration, Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
|
|
1
|
+
import { expect, it } from "@effect/vitest";
|
|
2
|
+
import { Duration, Effect, Layer, Schema as S } from "effect";
|
|
4
3
|
import {
|
|
5
4
|
AsyncCalc,
|
|
6
5
|
Calc,
|
|
@@ -24,280 +23,302 @@ import {
|
|
|
24
23
|
StateGroup,
|
|
25
24
|
Ui,
|
|
26
25
|
ui,
|
|
27
|
-
} from
|
|
26
|
+
} from "../index";
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
// at layer build time. These tests inject a recording instance the same way
|
|
31
|
-
// `profileScene` will — `Layer.locally` on the wiring — and assert each hook
|
|
32
|
-
// fires with the right names/counts, that spans end, and that the seam does not
|
|
33
|
-
// leak into unprofiled wiring.
|
|
34
|
-
|
|
35
|
-
const tick = (ms = 20) => Effect.sleep(Duration.millis(ms))
|
|
28
|
+
const tick = (ms = 20) => Effect.sleep(Duration.millis(ms));
|
|
36
29
|
|
|
37
30
|
const makeRecorder = () => {
|
|
38
|
-
const events: Array<{ readonly tag: string; readonly priority: Priority }> =
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const
|
|
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> = [];
|
|
43
38
|
const procedures: Array<{
|
|
44
|
-
readonly name: string
|
|
45
|
-
readonly channel: string
|
|
46
|
-
readonly eventTag: string
|
|
47
|
-
}> = []
|
|
48
|
-
const frames: Array<number> = []
|
|
49
|
-
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 };
|
|
50
45
|
const instrumentation: Instrumentation = {
|
|
51
46
|
eventDispatched: (tag, priority) => {
|
|
52
|
-
events.push({ tag, priority })
|
|
47
|
+
events.push({ tag, priority });
|
|
53
48
|
},
|
|
54
49
|
reducerRun: (name, eventTag) => {
|
|
55
|
-
reducers.push({ name, eventTag })
|
|
50
|
+
reducers.push({ name, eventTag });
|
|
56
51
|
return () => {
|
|
57
|
-
ends.reducer += 1
|
|
58
|
-
}
|
|
52
|
+
ends.reducer += 1;
|
|
53
|
+
};
|
|
59
54
|
},
|
|
60
55
|
stateUpdated: (name) => {
|
|
61
|
-
states.push(name)
|
|
56
|
+
states.push(name);
|
|
62
57
|
},
|
|
63
58
|
calcRecomputed: (name) => {
|
|
64
|
-
calcs.push(name)
|
|
59
|
+
calcs.push(name);
|
|
65
60
|
return () => {
|
|
66
|
-
ends.calc += 1
|
|
67
|
-
}
|
|
61
|
+
ends.calc += 1;
|
|
62
|
+
};
|
|
68
63
|
},
|
|
69
64
|
queryRun: (name) => {
|
|
70
|
-
queries.push(name)
|
|
65
|
+
queries.push(name);
|
|
71
66
|
return () => {
|
|
72
|
-
ends.query += 1
|
|
73
|
-
}
|
|
67
|
+
ends.query += 1;
|
|
68
|
+
};
|
|
74
69
|
},
|
|
75
70
|
procedureRun: (name, channel, eventTag) => {
|
|
76
|
-
procedures.push({ name, channel, eventTag })
|
|
71
|
+
procedures.push({ name, channel, eventTag });
|
|
77
72
|
return () => {
|
|
78
|
-
ends.procedure += 1
|
|
79
|
-
}
|
|
73
|
+
ends.procedure += 1;
|
|
74
|
+
};
|
|
80
75
|
},
|
|
81
76
|
uiRendered: () => () => {},
|
|
82
77
|
frame: (eventCount) => {
|
|
83
|
-
frames.push(eventCount)
|
|
78
|
+
frames.push(eventCount);
|
|
84
79
|
return () => {
|
|
85
|
-
ends.frame += 1
|
|
86
|
-
}
|
|
80
|
+
ends.frame += 1;
|
|
81
|
+
};
|
|
87
82
|
},
|
|
88
|
-
}
|
|
89
|
-
return {
|
|
90
|
-
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
instrumentation,
|
|
86
|
+
events,
|
|
87
|
+
reducers,
|
|
88
|
+
states,
|
|
89
|
+
calcs,
|
|
90
|
+
queries,
|
|
91
|
+
procedures,
|
|
92
|
+
frames,
|
|
93
|
+
ends,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
91
96
|
|
|
92
|
-
it.live(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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);
|
|
98
111
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
const TestLayer = FoldLive.pipe(
|
|
113
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
|
|
114
|
+
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
115
|
+
);
|
|
103
116
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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();
|
|
108
121
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
})
|
|
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
|
+
);
|
|
125
137
|
|
|
126
|
-
it.live(
|
|
127
|
-
const recorder = makeRecorder()
|
|
128
|
-
class Cell extends State.make(
|
|
129
|
-
class Wrote extends Event.make(
|
|
130
|
-
class Write extends Reducer.make(
|
|
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", {
|
|
143
|
+
states: [Cell],
|
|
144
|
+
events: [Wrote],
|
|
145
|
+
}) {}
|
|
146
|
+
const WriteLive = Reducer.live(Write, (_, event) => event.to);
|
|
132
147
|
|
|
133
148
|
const TestLayer = WriteLive.pipe(
|
|
134
149
|
Layer.provideMerge(Layer.mergeAll(State.live(Cell, 7), Engine)),
|
|
135
150
|
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
136
|
-
)
|
|
151
|
+
);
|
|
137
152
|
|
|
138
153
|
return Effect.gen(function* () {
|
|
139
|
-
yield* publish(
|
|
140
|
-
yield* tick()
|
|
141
|
-
|
|
142
|
-
expect(recorder.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
})
|
|
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
|
+
});
|
|
146
160
|
|
|
147
|
-
it.live(
|
|
148
|
-
const recorder = makeRecorder()
|
|
149
|
-
class Count extends State.make(
|
|
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) {}
|
|
150
164
|
class Inputs extends StateGroup.make(Count) {}
|
|
151
|
-
class Doubled extends Calc.make(
|
|
152
|
-
inputs: [StateGroup.select(Inputs,
|
|
165
|
+
class Doubled extends Calc.make("InstrDoubled", {
|
|
166
|
+
inputs: [StateGroup.select(Inputs, "instr-calc-count")],
|
|
153
167
|
output: S.Number,
|
|
154
168
|
}) {}
|
|
155
|
-
const DoubledLive = Calc.live(
|
|
169
|
+
const DoubledLive = Calc.live(
|
|
170
|
+
Doubled,
|
|
171
|
+
(inputs) => inputs["instr-calc-count"] * 2,
|
|
172
|
+
);
|
|
156
173
|
|
|
157
174
|
const TestLayer = DoubledLive.pipe(
|
|
158
|
-
Layer.provideMerge(StateGroup.live(Inputs, {
|
|
175
|
+
Layer.provideMerge(StateGroup.live(Inputs, { "instr-calc-count": 5 })),
|
|
159
176
|
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
160
|
-
)
|
|
177
|
+
);
|
|
161
178
|
|
|
162
179
|
return Effect.gen(function* () {
|
|
163
|
-
const derived = yield* Doubled.store
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
expect(derived.get()).toBe(10)
|
|
167
|
-
expect(
|
|
168
|
-
expect(recorder.calcs.length).toBe(builds)
|
|
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);
|
|
169
185
|
|
|
170
|
-
const source = yield* StateGroup.select(Inputs,
|
|
171
|
-
source.set(6)
|
|
172
|
-
yield* tick()
|
|
173
|
-
expect(derived.get()).toBe(12)
|
|
174
|
-
expect(recorder.calcs.length).toBe(builds + 1)
|
|
175
|
-
expect(recorder.calcs.every((name) => name ===
|
|
176
|
-
expect(recorder.ends.calc).toBe(recorder.calcs.length)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
})
|
|
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
|
+
});
|
|
181
196
|
|
|
182
|
-
it.live(
|
|
183
|
-
const recorder = makeRecorder()
|
|
184
|
-
class Count extends State.make(
|
|
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) {}
|
|
185
200
|
class Inputs extends StateGroup.make(Count) {}
|
|
186
|
-
class Fetched extends AsyncCalc.make(
|
|
187
|
-
inputs: [StateGroup.select(Inputs,
|
|
201
|
+
class Fetched extends AsyncCalc.make("InstrFetched", {
|
|
202
|
+
inputs: [StateGroup.select(Inputs, "instr-query-count")],
|
|
188
203
|
output: S.Number,
|
|
189
204
|
alwaysOn: true,
|
|
190
205
|
}) {}
|
|
191
206
|
const FetchedLive = AsyncCalc.live(Fetched, {
|
|
192
|
-
query: (inputs) =>
|
|
193
|
-
|
|
207
|
+
query: (inputs) =>
|
|
208
|
+
Effect.succeed(inputs["instr-query-count"] * 2).pipe(
|
|
209
|
+
Effect.delay(Duration.millis(5)),
|
|
210
|
+
),
|
|
211
|
+
});
|
|
194
212
|
|
|
195
213
|
const TestLayer = FetchedLive.pipe(
|
|
196
|
-
Layer.provideMerge(StateGroup.live(Inputs, {
|
|
214
|
+
Layer.provideMerge(StateGroup.live(Inputs, { "instr-query-count": 3 })),
|
|
197
215
|
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
198
|
-
)
|
|
216
|
+
);
|
|
199
217
|
|
|
200
218
|
return Effect.gen(function* () {
|
|
201
|
-
const store = yield* Fetched.store
|
|
202
|
-
yield* tick(40)
|
|
203
|
-
|
|
204
|
-
expect(
|
|
205
|
-
expect(recorder.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
})
|
|
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
|
+
});
|
|
209
226
|
|
|
210
|
-
it.live(
|
|
211
|
-
const recorder = makeRecorder()
|
|
212
|
-
class Kicked extends Event.make(
|
|
213
|
-
class Lane extends Channel.make(
|
|
214
|
-
class Work extends Procedure.make(
|
|
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", {
|
|
232
|
+
events: [Kicked],
|
|
233
|
+
channel: Lane,
|
|
234
|
+
}) {}
|
|
215
235
|
const WorkLive = Procedure.live(Work, function* () {
|
|
216
|
-
yield* tick(5)
|
|
217
|
-
})
|
|
236
|
+
yield* tick(5);
|
|
237
|
+
});
|
|
218
238
|
|
|
219
239
|
const TestLayer = Layer.mergeAll(Channel.live(Lane), WorkLive).pipe(
|
|
220
240
|
Layer.provideMerge(Engine),
|
|
221
241
|
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
222
|
-
)
|
|
242
|
+
);
|
|
223
243
|
|
|
224
244
|
return Effect.gen(function* () {
|
|
225
|
-
yield* publish(
|
|
226
|
-
yield* tick(40)
|
|
245
|
+
yield* publish("Normal", Event.construct(Kicked, {}));
|
|
246
|
+
yield* tick(40);
|
|
227
247
|
expect(recorder.procedures).toEqual([
|
|
228
|
-
{ name:
|
|
229
|
-
])
|
|
230
|
-
expect(recorder.ends.procedure).toBe(1)
|
|
231
|
-
}).pipe(Effect.provide(TestLayer))
|
|
232
|
-
})
|
|
248
|
+
{ name: "InstrWork", channel: "InstrLane", eventTag: "InstrKicked" },
|
|
249
|
+
]);
|
|
250
|
+
expect(recorder.ends.procedure).toBe(1);
|
|
251
|
+
}).pipe(Effect.provide(TestLayer));
|
|
252
|
+
});
|
|
233
253
|
|
|
234
|
-
|
|
235
|
-
// carry the handle for hosts, without leaking into the original scene. ─────────
|
|
236
|
-
|
|
237
|
-
class SceneCount extends State.make('instr-scene-count', S.Number) {}
|
|
254
|
+
class SceneCount extends State.make("instr-scene-count", S.Number) {}
|
|
238
255
|
class SceneStates extends StateGroup.make(SceneCount) {}
|
|
239
|
-
class SceneBumped extends Event.make(
|
|
240
|
-
class SceneBump extends Reducer.make(
|
|
256
|
+
class SceneBumped extends Event.make("InstrSceneBumped", S.Struct({})) {}
|
|
257
|
+
class SceneBump extends Reducer.make("InstrSceneBump", {
|
|
241
258
|
states: [SceneCount],
|
|
242
259
|
events: [SceneBumped],
|
|
243
260
|
}) {}
|
|
244
|
-
const SceneBumpLive = Reducer.live(SceneBump, (n) => n + 1)
|
|
261
|
+
const SceneBumpLive = Reducer.live(SceneBump, (n) => n + 1);
|
|
245
262
|
|
|
246
|
-
class SceneUi extends ui(
|
|
247
|
-
class SceneRoot extends Composition.make(
|
|
248
|
-
title:
|
|
263
|
+
class SceneUi extends ui("InstrScene")<{ props: { count: number } }>() {}
|
|
264
|
+
class SceneRoot extends Composition.make("InstrScene", {
|
|
265
|
+
title: "InstrScene",
|
|
249
266
|
states: [SceneStates],
|
|
250
267
|
events: [SceneBumped],
|
|
251
268
|
ui: SceneUi,
|
|
252
269
|
}) {}
|
|
253
270
|
const SceneRootLive = Composition.live(SceneRoot, function* () {
|
|
254
|
-
const count = yield* StateGroup.select(SceneStates,
|
|
255
|
-
return mount({ props: { count }, slots: {} })
|
|
256
|
-
})
|
|
271
|
+
const count = yield* StateGroup.select(SceneStates, "instr-scene-count");
|
|
272
|
+
return mount({ props: { count }, slots: {} });
|
|
273
|
+
});
|
|
257
274
|
|
|
258
275
|
const SceneApp = SceneRootLive.pipe(
|
|
259
|
-
Layer.provideMerge(
|
|
276
|
+
Layer.provideMerge(
|
|
277
|
+
provide(
|
|
278
|
+
SceneUi,
|
|
279
|
+
Ui.make(SceneUi, () => null),
|
|
280
|
+
),
|
|
281
|
+
),
|
|
260
282
|
Layer.provideMerge(
|
|
261
283
|
Layer.mergeAll(SceneBumpLive).pipe(
|
|
262
|
-
Layer.provideMerge(
|
|
284
|
+
Layer.provideMerge(
|
|
285
|
+
Layer.mergeAll(
|
|
286
|
+
Engine,
|
|
287
|
+
StateGroup.live(SceneStates, { "instr-scene-count": 0 }),
|
|
288
|
+
),
|
|
289
|
+
),
|
|
263
290
|
),
|
|
264
291
|
),
|
|
265
|
-
)
|
|
266
|
-
const InstrScene = scene(SceneRoot, { provide: [SceneApp] })
|
|
292
|
+
);
|
|
293
|
+
const InstrScene = scene(SceneRoot, { provide: [SceneApp] });
|
|
267
294
|
|
|
268
295
|
const runScene = (target: typeof InstrScene) => {
|
|
269
|
-
const layer = target.provide.reduce((a, b) => Layer.merge(a, b))
|
|
270
|
-
const runtime = ManagedRuntime.make(layer)
|
|
296
|
+
const layer = target.provide.reduce((a, b) => Layer.merge(a, b));
|
|
271
297
|
const drive = Effect.gen(function* () {
|
|
272
|
-
yield* publish(
|
|
273
|
-
yield* tick()
|
|
274
|
-
})
|
|
275
|
-
return {
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
test('profiledScene reaches nested layers and carries the handle', async () => {
|
|
279
|
-
const recorder = makeRecorder()
|
|
280
|
-
const profiled = profiledScene(InstrScene, recorder.instrumentation)
|
|
281
|
-
expect(sceneInstrumentation(profiled)).toBe(recorder.instrumentation)
|
|
298
|
+
yield* publish("High", Event.construct(SceneBumped, {}));
|
|
299
|
+
yield* tick();
|
|
300
|
+
});
|
|
301
|
+
return { layer, drive };
|
|
302
|
+
};
|
|
282
303
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
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);
|
|
291
311
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
})
|
|
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
|
+
);
|
|
@@ -1,48 +1,21 @@
|
|
|
1
1
|
import { Effect, FiberRef, GlobalValue } from 'effect'
|
|
2
2
|
import type { Priority } from './bus'
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* The profiler SPI — a synchronous, Effect-free hook surface the engine calls on
|
|
6
|
-
* its hot paths (the drain loop, store writes, calc recomputes, renders). It is
|
|
7
|
-
* Effect-free for the same reason `Store`/`Scheduler` are: these sites run
|
|
8
|
-
* synchronously inside a reduce frame or a React render, where forking or even
|
|
9
|
-
* building an Effect per call would dominate the very costs being measured.
|
|
10
|
-
*
|
|
11
|
-
* Span-shaped hooks return a {@link SpanEnd} thunk: call it when the measured
|
|
12
|
-
* work completes. The default implementation is {@link noopInstrumentation};
|
|
13
|
-
* every hook site captures its instance ONCE at layer build time (see
|
|
14
|
-
* `resolveInstrumentation`) and may skip name plumbing entirely when the
|
|
15
|
-
* captured instance IS the noop (identity check), so an unprofiled runtime pays
|
|
16
|
-
* nothing per operation.
|
|
17
|
-
*/
|
|
18
4
|
export type SpanEnd = () => void
|
|
19
5
|
|
|
20
6
|
export interface Instrumentation {
|
|
21
|
-
/** An event entered the drain frame (one call per envelope, per frame). */
|
|
22
7
|
eventDispatched(tag: string, priority: Priority): void
|
|
23
|
-
/** A reducer is folding one event; ends when its synchronous `apply` returns. */
|
|
24
8
|
reducerRun(name: string, eventTag: string): SpanEnd
|
|
25
|
-
/** A store accepted a genuinely new value (post-`Equal` gate). */
|
|
26
9
|
stateUpdated(name: string): void
|
|
27
|
-
/** A calc's projection actually ran (memo hits are not counted). */
|
|
28
10
|
calcRecomputed(name: string): SpanEnd
|
|
29
|
-
/** An async calc's query effect started; ends when it resolves/fails/cancels. */
|
|
30
11
|
queryRun(name: string): SpanEnd
|
|
31
|
-
/** A procedure body started on its channel; ends when the fiber completes. */
|
|
32
12
|
procedureRun(name: string, channel: string, eventTag: string): SpanEnd
|
|
33
|
-
/** A composition rendered one frame (host-injected: React `Compose` / proof engine). */
|
|
34
13
|
uiRendered(composition: string): SpanEnd
|
|
35
|
-
/** One drain tick: folds + routing for `eventCount` envelopes. */
|
|
36
14
|
frame(eventCount: number): SpanEnd
|
|
37
15
|
}
|
|
38
16
|
|
|
39
17
|
const noopSpanEnd: SpanEnd = () => {}
|
|
40
18
|
|
|
41
|
-
/**
|
|
42
|
-
* The shared do-nothing instrumentation. A single instance (and a single shared
|
|
43
|
-
* `SpanEnd`), so hook sites can detect "profiling off" by identity and skip
|
|
44
|
-
* their argument construction entirely.
|
|
45
|
-
*/
|
|
46
19
|
export const noopInstrumentation: Instrumentation = {
|
|
47
20
|
eventDispatched: () => {},
|
|
48
21
|
reducerRun: () => noopSpanEnd,
|
|
@@ -54,36 +27,14 @@ export const noopInstrumentation: Instrumentation = {
|
|
|
54
27
|
frame: () => noopSpanEnd,
|
|
55
28
|
}
|
|
56
29
|
|
|
57
|
-
/**
|
|
58
|
-
* The sanctioned profiling seam for CLOSED scenes — the same mechanism as
|
|
59
|
-
* `CurrentSeedOverrides` (`internal/seeds.ts`): a scene's layers are
|
|
60
|
-
* pre-composed, but a FiberRef set via `Layer.locally(ref, value)(layer)` IS
|
|
61
|
-
* visible inside the construction effects of nested layers. Engine/state/calc
|
|
62
|
-
* layers consult this ref once at build time and capture the resolved instance
|
|
63
|
-
* into their hot-path closures.
|
|
64
|
-
*
|
|
65
|
-
* Defaults to {@link noopInstrumentation} — production wiring never touches it.
|
|
66
|
-
* `profileScene` (in `@playfast/reform-profiler`) is the intended writer.
|
|
67
|
-
* `globalValue` keeps a single ref instance even if the module is loaded twice
|
|
68
|
-
* (duplicated bundles, HMR), so the writer and the reader always agree.
|
|
69
|
-
*/
|
|
70
30
|
export const CurrentInstrumentation: FiberRef.FiberRef<Instrumentation> =
|
|
71
31
|
GlobalValue.globalValue(Symbol.for('reform/CurrentInstrumentation'), () =>
|
|
72
32
|
FiberRef.unsafeMake<Instrumentation>(noopInstrumentation),
|
|
73
33
|
)
|
|
74
34
|
|
|
75
|
-
/**
|
|
76
|
-
* Resolve the ambient instrumentation at layer build time. Layers capture the
|
|
77
|
-
* result into their closures; they must not re-read it per operation.
|
|
78
|
-
*/
|
|
79
35
|
export const resolveInstrumentation: Effect.Effect<Instrumentation> =
|
|
80
36
|
FiberRef.get(CurrentInstrumentation)
|
|
81
37
|
|
|
82
|
-
/**
|
|
83
|
-
* The store `onChange` hook for one named state/calc: a bound
|
|
84
|
-
* `stateUpdated(name)` closure when profiled, the shared no-op otherwise — so
|
|
85
|
-
* an unprofiled store's write path never constructs the name-carrying call.
|
|
86
|
-
*/
|
|
87
38
|
export const stateUpdateHook = (
|
|
88
39
|
instrumentation: Instrumentation,
|
|
89
40
|
name: string,
|
package/src/runtime/loop.test.ts
CHANGED
|
@@ -2,10 +2,6 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Duration, Effect, Layer, Schema as S, TestClock } from 'effect'
|
|
3
3
|
import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
|
|
4
4
|
|
|
5
|
-
// Headless tests for the single frame-batched drain loop and channels, written
|
|
6
|
-
// with `@effect/vitest`: `it.live` for cases that lean on the real microtask
|
|
7
|
-
// queue (the store notifier), `it.effect` + `TestClock` for time-based channels.
|
|
8
|
-
|
|
9
5
|
it.live('a frame of events drains in one pass with a single notification flush', () => {
|
|
10
6
|
class Counter extends State.make('counter', S.Number) {}
|
|
11
7
|
class Bumped extends Event.make('Bumped', S.Struct({ to: S.Number })) {}
|
|
@@ -23,8 +19,6 @@ it.live('a frame of events drains in one pass with a single notification flush',
|
|
|
23
19
|
notifications.n += 1
|
|
24
20
|
})
|
|
25
21
|
|
|
26
|
-
// Three events in one tick batch into one frame, fold in order, and the
|
|
27
|
-
// coalesced store notifier wakes the subscriber exactly once.
|
|
28
22
|
yield* publish('Normal', Event.construct(Bumped, { to: 1 }))
|
|
29
23
|
yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
|
|
30
24
|
yield* publish('Normal', Event.construct(Bumped, { to: 3 }))
|
|
@@ -61,15 +55,11 @@ it.live('an exclusive channel serializes procedures that share it', () => {
|
|
|
61
55
|
yield* publish('Normal', Event.construct(StartA, {}))
|
|
62
56
|
yield* publish('Normal', Event.construct(StartB, {}))
|
|
63
57
|
yield* Effect.sleep(Duration.millis(60))
|
|
64
|
-
// Without exclusivity both would run concurrently (max === 2).
|
|
65
58
|
expect(concurrency.max).toBe(1)
|
|
66
59
|
}).pipe(Effect.provide(TestLayer))
|
|
67
60
|
})
|
|
68
61
|
|
|
69
62
|
it.live('two procedures sharing a channel each run once for one shared event', () => {
|
|
70
|
-
// Both procedures handle the SAME event on the SAME channel. The loop must
|
|
71
|
-
// offer the event to the channel once, so each procedure runs exactly once —
|
|
72
|
-
// not once per matching procedure (which would double every run).
|
|
73
63
|
class Shared extends Event.make('Shared', S.Struct({})) {}
|
|
74
64
|
class Lane extends Channel.make('Lane', { policy: { _tag: 'merge' } }) {}
|
|
75
65
|
|
|
@@ -115,9 +105,7 @@ it.effect('a debounce channel collapses a burst into a single run', () => {
|
|
|
115
105
|
yield* publish('Normal', Event.construct(Typed, {}))
|
|
116
106
|
yield* publish('Normal', Event.construct(Typed, {}))
|
|
117
107
|
yield* publish('Normal', Event.construct(Typed, {}))
|
|
118
|
-
// Let the loop route the frame into the channel and arm the debounce window.
|
|
119
108
|
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
120
|
-
// Advance the simulated clock past the window: the burst emits once.
|
|
121
109
|
yield* TestClock.adjust(Duration.millis(31))
|
|
122
110
|
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
123
111
|
expect(runs.n).toBe(1)
|
|
@@ -137,11 +125,9 @@ it.live('a reducer ignores events outside its declared set (no-op)', () => {
|
|
|
137
125
|
|
|
138
126
|
return Effect.gen(function* () {
|
|
139
127
|
const store = yield* Counter.store
|
|
140
|
-
// An event the reducer doesn't declare never reaches its fold.
|
|
141
128
|
yield* publish('Normal', Event.construct(Unrelated, {}))
|
|
142
129
|
yield* Effect.sleep(Duration.millis(10))
|
|
143
130
|
expect(store.get()).toBe(0)
|
|
144
|
-
// A declared event does.
|
|
145
131
|
yield* publish('Normal', Event.construct(Bump, {}))
|
|
146
132
|
yield* Effect.sleep(Duration.millis(10))
|
|
147
133
|
expect(store.get()).toBe(1)
|
|
@@ -163,15 +149,12 @@ it.effect('a latest channel cancels the in-flight run when a new event arrives',
|
|
|
163
149
|
const TestLayer = Layer.mergeAll(Channel.live(Latest), WorkLive).pipe(Layer.provideMerge(Engine))
|
|
164
150
|
|
|
165
151
|
return Effect.gen(function* () {
|
|
166
|
-
// First tick starts a run that parks on its 50ms sleep.
|
|
167
152
|
yield* publish('Normal', Event.construct(Tick, {}))
|
|
168
153
|
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
169
|
-
// Second tick switches the channel, interrupting the first run mid-sleep.
|
|
170
154
|
yield* publish('Normal', Event.construct(Tick, {}))
|
|
171
155
|
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
172
156
|
yield* TestClock.adjust(Duration.millis(60))
|
|
173
157
|
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
174
|
-
// Both runs began, but only the latest survived to completion.
|
|
175
158
|
expect(counts.started).toBe(2)
|
|
176
159
|
expect(counts.completed).toBe(1)
|
|
177
160
|
}).pipe(Effect.provide(TestLayer))
|