@playfast/reform 0.1.0 → 1.0.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/package.json +1 -1
- package/src/calc/calc.ts +13 -1
- package/src/calc/calcFamily.ts +6 -1
- package/src/channel/channel.ts +8 -1
- package/src/index.ts +31 -4
- package/src/internal/queryDriver.ts +8 -2
- package/src/internal/store.ts +11 -1
- package/src/runtime/appRuntime.ts +174 -0
- package/src/runtime/instrumentation.test.ts +303 -0
- package/src/runtime/instrumentation.ts +93 -0
- package/src/runtime/loop.ts +17 -4
- package/src/scene/scene.ts +34 -0
- package/src/state/state.ts +5 -2
- package/src/state/stateFamily.ts +11 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.1
|
|
4
|
+
"version": "1.0.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
|
|
7
7
|
"keywords": [
|
package/src/calc/calc.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
wireSources,
|
|
9
9
|
} from '../internal/sources'
|
|
10
10
|
import { resolveScheduler } from '../internal/scheduler'
|
|
11
|
+
import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
|
|
11
12
|
import { reuse } from '../internal/reuse'
|
|
12
13
|
import { makeDerivedStore, type Store } from '../internal/store'
|
|
13
14
|
import { readTracked } from '../internal/track'
|
|
@@ -93,6 +94,7 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
93
94
|
calc.store,
|
|
94
95
|
Effect.gen(function* () {
|
|
95
96
|
const scheduler = yield* resolveScheduler
|
|
97
|
+
const instrumentation = yield* resolveInstrumentation
|
|
96
98
|
const sources = yield* wireSources(calc.inputs, options.invalidateBy)
|
|
97
99
|
|
|
98
100
|
// Memo cell: key and output captured together so reads are always
|
|
@@ -107,17 +109,27 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
107
109
|
if (prev !== undefined && sameKey(key, prev.key)) {
|
|
108
110
|
return prev.output
|
|
109
111
|
}
|
|
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
|
+
const end = instrumentation.calcRecomputed(calc.manifest.name)
|
|
110
116
|
const fresh = compute(args)
|
|
111
117
|
// With `reuse`, a recompute that lands value-equal to the previous
|
|
112
118
|
// output returns the previous reference — the derived store's Equal
|
|
113
119
|
// gate then wakes no subscriber at all.
|
|
114
120
|
const output =
|
|
115
121
|
options.reuse === true && prev !== undefined ? reuse(prev.output, fresh) : fresh
|
|
122
|
+
end()
|
|
116
123
|
MutableRef.set(memo, { key, output })
|
|
117
124
|
return output
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
const derived = makeDerivedStore(
|
|
127
|
+
const derived = makeDerivedStore(
|
|
128
|
+
recompute,
|
|
129
|
+
sources.subscribe,
|
|
130
|
+
scheduler,
|
|
131
|
+
stateUpdateHook(instrumentation, calc.manifest.name),
|
|
132
|
+
)
|
|
121
133
|
yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
|
|
122
134
|
return derived.store
|
|
123
135
|
}),
|
package/src/calc/calcFamily.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
wireSources,
|
|
9
9
|
} from '../internal/sources'
|
|
10
10
|
import { resolveScheduler } from '../internal/scheduler'
|
|
11
|
+
import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
|
|
11
12
|
import { makeDerivedStore, type Store } from '../internal/store'
|
|
12
13
|
import { readTracked } from '../internal/track'
|
|
13
14
|
import { type FamilyOptions, type FamilyStore } from '../state/stateFamily'
|
|
@@ -115,6 +116,8 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
115
116
|
family.store,
|
|
116
117
|
Effect.gen(function* () {
|
|
117
118
|
const scheduler = yield* resolveScheduler
|
|
119
|
+
const instrumentation = yield* resolveInstrumentation
|
|
120
|
+
const onOutputChange = stateUpdateHook(instrumentation, family.manifest.name)
|
|
118
121
|
// Wired ONCE for the whole family — members share the sensing surface.
|
|
119
122
|
const sources = yield* wireSources(family.inputs, options.invalidateBy)
|
|
120
123
|
const entries = new Map<K, { readonly store: Store<Out>; readonly unsubscribe: () => void }>()
|
|
@@ -144,11 +147,13 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
144
147
|
if (prev !== undefined && sameKey(memoKey, prev.key)) {
|
|
145
148
|
return prev.output
|
|
146
149
|
}
|
|
150
|
+
const end = instrumentation.calcRecomputed(family.manifest.name)
|
|
147
151
|
const output = body(args)
|
|
152
|
+
end()
|
|
148
153
|
MutableRef.set(memo, { key: memoKey, output })
|
|
149
154
|
return output
|
|
150
155
|
}
|
|
151
|
-
return makeDerivedStore(recompute, sources.subscribe, scheduler)
|
|
156
|
+
return makeDerivedStore(recompute, sources.subscribe, scheduler, onOutputChange)
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
// The `StateFamily` ref-count idiom: evict on the next microtask once a
|
package/src/channel/channel.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Chunk, Context, type Duration, Effect, Layer, Match, Option, Queue, Stream } from 'effect'
|
|
2
2
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
3
|
import type { Tagged } from '../runtime/bus'
|
|
4
|
+
import { resolveInstrumentation } from '../runtime/instrumentation'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* How a channel schedules the procedure work routed to it. Channels generalize
|
|
@@ -217,6 +218,7 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
217
218
|
return
|
|
218
219
|
}
|
|
219
220
|
const procedures = yield* Procedures
|
|
221
|
+
const instrumentation = yield* resolveInstrumentation
|
|
220
222
|
const queue = yield* Queue.unbounded<Tagged>()
|
|
221
223
|
|
|
222
224
|
// Run every procedure on THIS channel that handles the event. Read the live
|
|
@@ -232,7 +234,12 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
232
234
|
return Effect.forEach(
|
|
233
235
|
matching,
|
|
234
236
|
(procedure) =>
|
|
235
|
-
|
|
237
|
+
// The `procedureRun` span covers the whole body, success/failure/cancel
|
|
238
|
+
// alike (`ensuring` fires on all three).
|
|
239
|
+
Effect.sync(() => instrumentation.procedureRun(procedure.name, name, event._tag)).pipe(
|
|
240
|
+
Effect.flatMap((endSpan) =>
|
|
241
|
+
procedure.run(event).pipe(Effect.ensuring(Effect.sync(endSpan))),
|
|
242
|
+
),
|
|
236
243
|
Effect.tapErrorCause((cause) =>
|
|
237
244
|
Effect.logError(`reform: procedure on channel '${name}' failed`, cause),
|
|
238
245
|
),
|
package/src/index.ts
CHANGED
|
@@ -162,22 +162,49 @@ export { type SlotChild } from './compose/slot'
|
|
|
162
162
|
|
|
163
163
|
// A scene bundles a composition with the closed wiring that runs it — the single
|
|
164
164
|
// handle the react host, proofs, and the dev tool all consume.
|
|
165
|
-
export {
|
|
165
|
+
export {
|
|
166
|
+
isScene,
|
|
167
|
+
type MountedServices,
|
|
168
|
+
profiledScene,
|
|
169
|
+
type Scene,
|
|
170
|
+
scene,
|
|
171
|
+
sceneInstrumentation,
|
|
172
|
+
seedScene,
|
|
173
|
+
} from './scene/scene'
|
|
166
174
|
export type { SeedsOf } from './state/stateGroup'
|
|
167
175
|
|
|
168
176
|
// Runtime surface for hosts (`@reform/react`) and headless tests.
|
|
169
|
-
export { Bus, type Envelope, type Priority, publish, type Tagged } from './runtime/bus'
|
|
170
|
-
|
|
177
|
+
export { Bus, busLayer, type Envelope, type Priority, publish, type Tagged } from './runtime/bus'
|
|
178
|
+
// The renderer-neutral scene runtime: hosts layer their lifecycle on top;
|
|
179
|
+
// headless tests/proofs/drive scripts consume it directly.
|
|
180
|
+
export {
|
|
181
|
+
type AppRuntime,
|
|
182
|
+
type FeatureMountHandlers,
|
|
183
|
+
makeAppRuntime,
|
|
184
|
+
type RuntimeHandle,
|
|
185
|
+
} from './runtime/appRuntime'
|
|
186
|
+
export { Engine, type ReducerEntry, Reducers, reducersLayer } from './runtime/loop'
|
|
187
|
+
// The profiler SPI: synchronous hooks the engine calls on its hot paths, plus
|
|
188
|
+
// the FiberRef seam `@playfast/reform-profiler` writes through (`profiledScene`).
|
|
189
|
+
export {
|
|
190
|
+
CurrentInstrumentation,
|
|
191
|
+
type Instrumentation,
|
|
192
|
+
noopInstrumentation,
|
|
193
|
+
resolveInstrumentation,
|
|
194
|
+
type SpanEnd,
|
|
195
|
+
} from './runtime/instrumentation'
|
|
171
196
|
// The runtime-wide registry of live query handles. `AsyncCalc.invalidate` /
|
|
172
197
|
// `AsyncCalc.refetch` and provider layers act on a query by name through it.
|
|
173
|
-
export { Queries, type QueryHandle, type QueryRegistry } from './runtime/queries'
|
|
198
|
+
export { Queries, queriesLayer, type QueryHandle, type QueryRegistry } from './runtime/queries'
|
|
174
199
|
export {
|
|
175
200
|
type ChannelClass,
|
|
176
201
|
type ChannelPolicy,
|
|
177
202
|
type ChannelRuntime,
|
|
178
203
|
Channels,
|
|
204
|
+
channelsLayer,
|
|
179
205
|
type ProcedureEntry,
|
|
180
206
|
Procedures,
|
|
207
|
+
proceduresLayer,
|
|
181
208
|
} from './channel/channel'
|
|
182
209
|
export { CurrentTracker, type Subscribable, type Tracker } from './internal/track'
|
|
183
210
|
export { CaptureSink, type CaptureSinkApi, type UiCapture } from './internal/capture'
|
|
@@ -14,6 +14,7 @@ import { type AnyAsyncData } from '../calc/asyncData'
|
|
|
14
14
|
import { empty, type QueryState, toAsyncData } from '../calc/queryState'
|
|
15
15
|
import { Queries, type QueryHandle } from '../runtime/queries'
|
|
16
16
|
import { reuse } from './reuse'
|
|
17
|
+
import { resolveInstrumentation } from '../runtime/instrumentation'
|
|
17
18
|
import { resolveQueryStore } from './queryStore'
|
|
18
19
|
import { resolveScheduler } from './scheduler'
|
|
19
20
|
import {
|
|
@@ -139,6 +140,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
139
140
|
): Effect.Effect<QueryDriver<A, E>, never, InputStores<Inputs> | R | Scope.Scope> =>
|
|
140
141
|
Effect.gen(function* () {
|
|
141
142
|
const scheduler = yield* resolveScheduler
|
|
143
|
+
const instrumentation = yield* resolveInstrumentation
|
|
142
144
|
const sources = yield* wireSources(options.inputs, options.invalidateBy)
|
|
143
145
|
const extraKey = options.extraKey
|
|
144
146
|
const persist = options.persist
|
|
@@ -272,9 +274,13 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
272
274
|
// One run of the query, folded into the cell. A failure becomes `Error`; an
|
|
273
275
|
// interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug in
|
|
274
276
|
// the body) is logged and isolated. A `Success` reports its generation through
|
|
275
|
-
// `onSettled` after the write, then persists (best-effort, trailing).
|
|
277
|
+
// `onSettled` after the write, then persists (best-effort, trailing). The
|
|
278
|
+
// `queryRun` span covers start → settle/cancel (`ensuring` fires on all three).
|
|
276
279
|
const runQuery = (request: Request): Effect.Effect<void, never, R> =>
|
|
277
|
-
|
|
280
|
+
Effect.sync(() => instrumentation.queryRun(options.name)).pipe(
|
|
281
|
+
Effect.flatMap((endSpan) =>
|
|
282
|
+
options.query(request.args).pipe(Effect.ensuring(Effect.sync(endSpan))),
|
|
283
|
+
),
|
|
278
284
|
Effect.tapDefect((defect) =>
|
|
279
285
|
Effect.logError(`reform: ${options.label} '${options.name}' query defect`, defect),
|
|
280
286
|
),
|
package/src/internal/store.ts
CHANGED
|
@@ -30,7 +30,14 @@ export interface Store<in out A> extends Inspectable {
|
|
|
30
30
|
subscribe(listener: () => void): () => void
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/** Default change hook — a shared no-op so unprofiled stores pay one skipped call. */
|
|
34
|
+
const noChange = (): void => {}
|
|
35
|
+
|
|
36
|
+
export const makeStore = <A>(
|
|
37
|
+
initial: A,
|
|
38
|
+
scheduler: Scheduler = defaultScheduler,
|
|
39
|
+
onChange: () => void = noChange,
|
|
40
|
+
): Store<A> => {
|
|
34
41
|
// The store is a mutable reactive slot by design, so its value lives in a
|
|
35
42
|
// `MutableRef` we update in place (no reassigned binding).
|
|
36
43
|
const current = MutableRef.make(initial)
|
|
@@ -46,6 +53,7 @@ export const makeStore = <A>(initial: A, scheduler: Scheduler = defaultScheduler
|
|
|
46
53
|
}
|
|
47
54
|
MutableRef.set(current, next)
|
|
48
55
|
MutableRef.update(version, (count) => count + 1)
|
|
56
|
+
onChange()
|
|
49
57
|
scheduler.schedule(listeners)
|
|
50
58
|
},
|
|
51
59
|
subscribe: (listener) => {
|
|
@@ -74,6 +82,7 @@ export const makeDerivedStore = <A>(
|
|
|
74
82
|
compute: () => A,
|
|
75
83
|
subscribeUpstream: (onChange: () => void) => () => void,
|
|
76
84
|
scheduler: Scheduler = defaultScheduler,
|
|
85
|
+
onOutputChange: () => void = noChange,
|
|
77
86
|
): { readonly store: Store<A>; readonly unsubscribe: () => void } => {
|
|
78
87
|
const listeners = new Set<() => void>()
|
|
79
88
|
const version = MutableRef.make(0)
|
|
@@ -86,6 +95,7 @@ export const makeDerivedStore = <A>(
|
|
|
86
95
|
MutableRef.set(seen, { value: next })
|
|
87
96
|
if (previous === undefined || !Equal.equals(previous.value, next)) {
|
|
88
97
|
MutableRef.update(version, (count) => count + 1)
|
|
98
|
+
onOutputChange()
|
|
89
99
|
scheduler.schedule(listeners)
|
|
90
100
|
}
|
|
91
101
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Cause, Context, Effect, Exit, Layer, ManagedRuntime, Option, Scope } from 'effect'
|
|
2
|
+
import { FeatureLoadFailed, forceSync } from '../internal/errors'
|
|
3
|
+
import { type FeatureBinding, mountFeature } from '../feature/feature'
|
|
4
|
+
import { type Node } from '../ui/node'
|
|
5
|
+
import { type MountedServices, type Scene, sceneInstrumentation } from '../scene/scene'
|
|
6
|
+
import { publish, type Priority, type Tagged } from './bus'
|
|
7
|
+
import { type Instrumentation } from './instrumentation'
|
|
8
|
+
|
|
9
|
+
// The renderer-neutral scene runtime: one `ManagedRuntime` over a scene's closed
|
|
10
|
+
// layers, with the read/render/dispatch/feature-mount surface every host shares.
|
|
11
|
+
// `@playfast/reform-react` builds its React lifecycle (StrictMode-deferred
|
|
12
|
+
// dispose, providers) ON TOP of this; headless tests, proofs, and drive scripts
|
|
13
|
+
// consume it directly — the "testing runtime" with no React and no harness.
|
|
14
|
+
|
|
15
|
+
/** The lazy-feature lifecycle callbacks a host hands to `mountFeature`. */
|
|
16
|
+
export interface FeatureMountHandlers {
|
|
17
|
+
readonly onLive: (runtime: RuntimeHandle) => void
|
|
18
|
+
readonly onFailed: (error: FeatureLoadFailed) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The capabilities a host needs from a built reform runtime — what `Compose`
|
|
23
|
+
* (React) and the proof facade depend on. A feature mount yields a child handle
|
|
24
|
+
* over the feature's merged context, sharing the parent's render/dispatch.
|
|
25
|
+
*/
|
|
26
|
+
export interface RuntimeHandle {
|
|
27
|
+
/**
|
|
28
|
+
* Resolve any reform DI tag (a composition's mounted logic, a slot's wired
|
|
29
|
+
* child) from the app context. The mounted runtime provides every reform
|
|
30
|
+
* service, so this never fails for tags from the same app.
|
|
31
|
+
*/
|
|
32
|
+
read<A>(tag: Context.Tag<A, A>): A
|
|
33
|
+
/** Run a fully-provided, synchronous render to its node (D2). */
|
|
34
|
+
runRender(effect: Effect.Effect<Node, never, never>): Node
|
|
35
|
+
/** Dispatch a runtime/boot event onto the bus. */
|
|
36
|
+
dispatch(priority: Priority, event: Tagged): void
|
|
37
|
+
/**
|
|
38
|
+
* Mount a feature against this runtime: load its module (lazy = dynamic import),
|
|
39
|
+
* build its `.live` layer on a fresh scope sharing this runtime's engine, and
|
|
40
|
+
* call `onLive` with a child runtime that reads the feature's composition/stores
|
|
41
|
+
* (and the shared services), or `onFailed` with a typed `FeatureLoadFailed`.
|
|
42
|
+
* Returns a disposer that closes the feature's scope — stopping its fibers and
|
|
43
|
+
* reclaiming its reducers/stores.
|
|
44
|
+
*/
|
|
45
|
+
mountFeature(binding: FeatureBinding, handlers: FeatureMountHandlers): () => void
|
|
46
|
+
/**
|
|
47
|
+
* The scene's profiling hooks (`noopInstrumentation` unless `profileScene`d).
|
|
48
|
+
* On the handle (not only the app runtime) because render spans are recorded
|
|
49
|
+
* by whatever holds the handle — React `Compose`, the proof engine — and a
|
|
50
|
+
* feature's child handle must record into the same profiler as its parent.
|
|
51
|
+
*/
|
|
52
|
+
readonly instrumentation: Instrumentation
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A built scene runtime: the handle plus its boot/dispose lifecycle. */
|
|
56
|
+
export interface AppRuntime extends RuntimeHandle {
|
|
57
|
+
/** Dispatch the scene's boot events, once — later calls are no-ops. */
|
|
58
|
+
boot(): void
|
|
59
|
+
/** Interrupt the runtime and latch the handle inert. Immediate and idempotent. */
|
|
60
|
+
dispose(): void
|
|
61
|
+
/** True once disposed, so a host can rebuild rather than reuse a dead runtime. */
|
|
62
|
+
isDisposed(): boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the concrete runtime over a `ManagedRuntime` made from a scene's closed
|
|
67
|
+
* layers. The mounted runtime provides every reform tag, so reading any one
|
|
68
|
+
* needs no further context; we erase the requirement the generic tag carries.
|
|
69
|
+
*/
|
|
70
|
+
export const makeAppRuntime = (scene: Scene): AppRuntime => {
|
|
71
|
+
const instrumentation = sceneInstrumentation(scene)
|
|
72
|
+
// A scene always provides at least one layer; merge them into the app layer.
|
|
73
|
+
const layer = scene.provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
|
|
74
|
+
const runtime = ManagedRuntime.make(layer)
|
|
75
|
+
// The full app context (all services), captured once. A feature's `.live` layer
|
|
76
|
+
// is built against this so it shares the one engine (bus + registries). Typed as
|
|
77
|
+
// the host's `MountedServices` subset; at runtime it carries every service.
|
|
78
|
+
const rootContext = forceSync(() => runtime.runSync(Effect.context<MountedServices>()))
|
|
79
|
+
|
|
80
|
+
// Past actual dispose the `ManagedRuntime` is interrupted and every `runSync` /
|
|
81
|
+
// `runFork` against it throws `ManagedRuntime disposed`. A host can still render
|
|
82
|
+
// a tearing-down tree, and forked fibers (a RemoteState load, a boot dispatch)
|
|
83
|
+
// outlive the host cleanup — so the runtime is made inert past dispose instead
|
|
84
|
+
// of throwing into a render. A `const` latch mutated in place.
|
|
85
|
+
const status = { disposed: false, booted: false }
|
|
86
|
+
|
|
87
|
+
// A render is a self-contained `Effect<Node, never, never>` (no service needs),
|
|
88
|
+
// so once the managed runtime is gone it still runs on the default runtime —
|
|
89
|
+
// yielding a real node from the current store values, never a throw mid-render.
|
|
90
|
+
const runRender = (effect: Effect.Effect<Node, never, never>): Node =>
|
|
91
|
+
status.disposed ? Effect.runSync(effect) : runtime.runSync(effect)
|
|
92
|
+
|
|
93
|
+
// A dispatch past dispose has no live bus to reach; drop it.
|
|
94
|
+
const dispatch = (priority: Priority, event: Tagged): void => {
|
|
95
|
+
if (status.disposed) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
runtime.runFork(publish(priority, event))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A child runtime over a feature's merged context (feature services + the shared
|
|
102
|
+
// engine). Reads resolve from that context; renders/dispatch share the
|
|
103
|
+
// inert-past-dispose helpers above.
|
|
104
|
+
const makeChildRuntime = (context: Context.Context<unknown>): RuntimeHandle => ({
|
|
105
|
+
read: <A>(tag: Context.Tag<A, A>): A => Context.unsafeGet(context, tag),
|
|
106
|
+
runRender,
|
|
107
|
+
dispatch,
|
|
108
|
+
mountFeature: (binding, handlers) => mountFeatureOnto(binding, handlers),
|
|
109
|
+
instrumentation,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const mountFeatureOnto = (
|
|
113
|
+
binding: FeatureBinding,
|
|
114
|
+
handlers: FeatureMountHandlers,
|
|
115
|
+
): (() => void) => {
|
|
116
|
+
// Don't mount onto a runtime that's already torn down — the tree is unmounting.
|
|
117
|
+
if (status.disposed) {
|
|
118
|
+
return () => {}
|
|
119
|
+
}
|
|
120
|
+
const scope = Effect.runSync(Scope.make())
|
|
121
|
+
runtime.runFork(
|
|
122
|
+
mountFeature(binding, rootContext).pipe(
|
|
123
|
+
Effect.provideService(Scope.Scope, scope),
|
|
124
|
+
Effect.matchCause({
|
|
125
|
+
onFailure: (cause) =>
|
|
126
|
+
handlers.onFailed(
|
|
127
|
+
Option.getOrElse(Cause.failureOption(cause), () => new FeatureLoadFailed({ cause })),
|
|
128
|
+
),
|
|
129
|
+
onSuccess: (context) => handlers.onLive(makeChildRuntime(context)),
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
return () => {
|
|
134
|
+
// The scope's finalizers (reducer/store reclaim) are self-contained, so when
|
|
135
|
+
// the managed runtime is already disposed they close on the default runtime
|
|
136
|
+
// rather than throwing through a dead `runFork`.
|
|
137
|
+
const close = Scope.close(scope, Exit.succeed(undefined))
|
|
138
|
+
if (status.disposed) {
|
|
139
|
+
Effect.runFork(close)
|
|
140
|
+
} else {
|
|
141
|
+
runtime.runFork(close)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const app: AppRuntime = {
|
|
147
|
+
// Read from the captured context (it carries every service), not
|
|
148
|
+
// `runtime.runSync(tag)`, so a read from a tearing-down tree can't throw
|
|
149
|
+
// `ManagedRuntime disposed` — and it drops the cast the runSync form needed.
|
|
150
|
+
read: <A>(tag: Context.Tag<A, A>): A => Context.unsafeGet(rootContext, tag),
|
|
151
|
+
runRender,
|
|
152
|
+
dispatch,
|
|
153
|
+
mountFeature: (binding, handlers) => mountFeatureOnto(binding, handlers),
|
|
154
|
+
boot: () => {
|
|
155
|
+
if (status.disposed || status.booted) {
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
status.booted = true
|
|
159
|
+
Option.fromNullable(scene.boot)
|
|
160
|
+
.pipe(Option.getOrElse((): ReadonlyArray<Tagged> => []))
|
|
161
|
+
.forEach((event) => dispatch('High', event))
|
|
162
|
+
},
|
|
163
|
+
dispose: () => {
|
|
164
|
+
if (status.disposed) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
status.disposed = true
|
|
168
|
+
void runtime.dispose()
|
|
169
|
+
},
|
|
170
|
+
isDisposed: () => status.disposed,
|
|
171
|
+
instrumentation,
|
|
172
|
+
}
|
|
173
|
+
return app
|
|
174
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { test } from 'vitest'
|
|
3
|
+
import { Duration, Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
|
|
4
|
+
import {
|
|
5
|
+
AsyncCalc,
|
|
6
|
+
Calc,
|
|
7
|
+
Channel,
|
|
8
|
+
Composition,
|
|
9
|
+
CurrentInstrumentation,
|
|
10
|
+
Engine,
|
|
11
|
+
Event,
|
|
12
|
+
type Instrumentation,
|
|
13
|
+
mount,
|
|
14
|
+
noopInstrumentation,
|
|
15
|
+
type Priority,
|
|
16
|
+
Procedure,
|
|
17
|
+
profiledScene,
|
|
18
|
+
provide,
|
|
19
|
+
publish,
|
|
20
|
+
Reducer,
|
|
21
|
+
scene,
|
|
22
|
+
sceneInstrumentation,
|
|
23
|
+
State,
|
|
24
|
+
StateGroup,
|
|
25
|
+
Ui,
|
|
26
|
+
ui,
|
|
27
|
+
} from '../index'
|
|
28
|
+
|
|
29
|
+
// The profiler SPI: every engine hot path consults `CurrentInstrumentation` once
|
|
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))
|
|
36
|
+
|
|
37
|
+
const makeRecorder = () => {
|
|
38
|
+
const events: Array<{ readonly tag: string; readonly priority: Priority }> = []
|
|
39
|
+
const reducers: Array<{ readonly name: string; readonly eventTag: string }> = []
|
|
40
|
+
const states: Array<string> = []
|
|
41
|
+
const calcs: Array<string> = []
|
|
42
|
+
const queries: Array<string> = []
|
|
43
|
+
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 }
|
|
50
|
+
const instrumentation: Instrumentation = {
|
|
51
|
+
eventDispatched: (tag, priority) => {
|
|
52
|
+
events.push({ tag, priority })
|
|
53
|
+
},
|
|
54
|
+
reducerRun: (name, eventTag) => {
|
|
55
|
+
reducers.push({ name, eventTag })
|
|
56
|
+
return () => {
|
|
57
|
+
ends.reducer += 1
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
stateUpdated: (name) => {
|
|
61
|
+
states.push(name)
|
|
62
|
+
},
|
|
63
|
+
calcRecomputed: (name) => {
|
|
64
|
+
calcs.push(name)
|
|
65
|
+
return () => {
|
|
66
|
+
ends.calc += 1
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
queryRun: (name) => {
|
|
70
|
+
queries.push(name)
|
|
71
|
+
return () => {
|
|
72
|
+
ends.query += 1
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
procedureRun: (name, channel, eventTag) => {
|
|
76
|
+
procedures.push({ name, channel, eventTag })
|
|
77
|
+
return () => {
|
|
78
|
+
ends.procedure += 1
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
uiRendered: () => () => {},
|
|
82
|
+
frame: (eventCount) => {
|
|
83
|
+
frames.push(eventCount)
|
|
84
|
+
return () => {
|
|
85
|
+
ends.frame += 1
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
return { instrumentation, events, reducers, states, calcs, queries, procedures, frames, ends }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
it.live('the engine reports frames, events, reducer spans, and state updates', () => {
|
|
93
|
+
const recorder = makeRecorder()
|
|
94
|
+
class Counter extends State.make('instr-counter', S.Number) {}
|
|
95
|
+
class Bumped extends Event.make('InstrBumped', S.Struct({ to: S.Number })) {}
|
|
96
|
+
class Fold extends Reducer.make('InstrFold', { states: [Counter], events: [Bumped] }) {}
|
|
97
|
+
const FoldLive = Reducer.live(Fold, (_, event) => event.to)
|
|
98
|
+
|
|
99
|
+
const TestLayer = FoldLive.pipe(
|
|
100
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
|
|
101
|
+
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return Effect.gen(function* () {
|
|
105
|
+
yield* publish('High', Event.construct(Bumped, { to: 1 }))
|
|
106
|
+
yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
|
|
107
|
+
yield* tick()
|
|
108
|
+
|
|
109
|
+
// Both events landed in one frame; High folds before Normal.
|
|
110
|
+
expect(recorder.frames).toEqual([2])
|
|
111
|
+
expect(recorder.ends.frame).toBe(1)
|
|
112
|
+
expect(recorder.events).toEqual([
|
|
113
|
+
{ tag: 'InstrBumped', priority: 'High' },
|
|
114
|
+
{ tag: 'InstrBumped', priority: 'Normal' },
|
|
115
|
+
])
|
|
116
|
+
expect(recorder.reducers).toEqual([
|
|
117
|
+
{ name: 'InstrFold', eventTag: 'InstrBumped' },
|
|
118
|
+
{ name: 'InstrFold', eventTag: 'InstrBumped' },
|
|
119
|
+
])
|
|
120
|
+
expect(recorder.ends.reducer).toBe(2)
|
|
121
|
+
// Two genuinely new values (0 -> 1 -> 2), one update per fold.
|
|
122
|
+
expect(recorder.states).toEqual(['instr-counter', 'instr-counter'])
|
|
123
|
+
}).pipe(Effect.provide(TestLayer))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it.live('an equal-value write is not reported as a state update', () => {
|
|
127
|
+
const recorder = makeRecorder()
|
|
128
|
+
class Cell extends State.make('instr-cell', S.Number) {}
|
|
129
|
+
class Wrote extends Event.make('InstrWrote', S.Struct({ to: S.Number })) {}
|
|
130
|
+
class Write extends Reducer.make('InstrWrite', { states: [Cell], events: [Wrote] }) {}
|
|
131
|
+
const WriteLive = Reducer.live(Write, (_, event) => event.to)
|
|
132
|
+
|
|
133
|
+
const TestLayer = WriteLive.pipe(
|
|
134
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Cell, 7), Engine)),
|
|
135
|
+
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return Effect.gen(function* () {
|
|
139
|
+
yield* publish('High', Event.construct(Wrote, { to: 7 }))
|
|
140
|
+
yield* tick()
|
|
141
|
+
// The reducer ran, but the store's Equal gate suppressed the write.
|
|
142
|
+
expect(recorder.reducers.length).toBe(1)
|
|
143
|
+
expect(recorder.states).toEqual([])
|
|
144
|
+
}).pipe(Effect.provide(TestLayer))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it.live('calc recomputes are spanned; memo hits are not counted', () => {
|
|
148
|
+
const recorder = makeRecorder()
|
|
149
|
+
class Count extends State.make('instr-calc-count', S.Number) {}
|
|
150
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
151
|
+
class Doubled extends Calc.make('InstrDoubled', {
|
|
152
|
+
inputs: [StateGroup.select(Inputs, 'instr-calc-count')],
|
|
153
|
+
output: S.Number,
|
|
154
|
+
}) {}
|
|
155
|
+
const DoubledLive = Calc.live(Doubled, (inputs) => inputs['instr-calc-count'] * 2)
|
|
156
|
+
|
|
157
|
+
const TestLayer = DoubledLive.pipe(
|
|
158
|
+
Layer.provideMerge(StateGroup.live(Inputs, { 'instr-calc-count': 5 })),
|
|
159
|
+
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return Effect.gen(function* () {
|
|
163
|
+
const derived = yield* Doubled.store
|
|
164
|
+
// The derived store computes once at build; repeated reads hit the memo.
|
|
165
|
+
const builds = recorder.calcs.length
|
|
166
|
+
expect(derived.get()).toBe(10)
|
|
167
|
+
expect(derived.get()).toBe(10)
|
|
168
|
+
expect(recorder.calcs.length).toBe(builds)
|
|
169
|
+
|
|
170
|
+
const source = yield* StateGroup.select(Inputs, 'instr-calc-count').store
|
|
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 === 'InstrDoubled')).toBe(true)
|
|
176
|
+
expect(recorder.ends.calc).toBe(recorder.calcs.length)
|
|
177
|
+
// The calc's output moved twice (build seeds silently; 10 -> 12 notifies once).
|
|
178
|
+
expect(recorder.states).toContain('InstrDoubled')
|
|
179
|
+
}).pipe(Effect.provide(TestLayer))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it.live('async calc query runs are spanned start-to-settle', () => {
|
|
183
|
+
const recorder = makeRecorder()
|
|
184
|
+
class Count extends State.make('instr-query-count', S.Number) {}
|
|
185
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
186
|
+
class Fetched extends AsyncCalc.make('InstrFetched', {
|
|
187
|
+
inputs: [StateGroup.select(Inputs, 'instr-query-count')],
|
|
188
|
+
output: S.Number,
|
|
189
|
+
alwaysOn: true,
|
|
190
|
+
}) {}
|
|
191
|
+
const FetchedLive = AsyncCalc.live(Fetched, {
|
|
192
|
+
query: (inputs) => Effect.succeed(inputs['instr-query-count'] * 2).pipe(Effect.delay(Duration.millis(5))),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const TestLayer = FetchedLive.pipe(
|
|
196
|
+
Layer.provideMerge(StateGroup.live(Inputs, { 'instr-query-count': 3 })),
|
|
197
|
+
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return Effect.gen(function* () {
|
|
201
|
+
const store = yield* Fetched.store
|
|
202
|
+
yield* tick(40)
|
|
203
|
+
// The run fiber picked the request, started the span, and ended it on settle.
|
|
204
|
+
expect(store.get()._tag).toBe('Success')
|
|
205
|
+
expect(recorder.queries).toEqual(['InstrFetched'])
|
|
206
|
+
expect(recorder.ends.query).toBe(1)
|
|
207
|
+
}).pipe(Effect.provide(TestLayer))
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it.live('procedure runs are spanned with their channel and trigger tag', () => {
|
|
211
|
+
const recorder = makeRecorder()
|
|
212
|
+
class Kicked extends Event.make('InstrKicked', S.Struct({})) {}
|
|
213
|
+
class Lane extends Channel.make('InstrLane', { policy: { _tag: 'merge' } }) {}
|
|
214
|
+
class Work extends Procedure.make('InstrWork', { events: [Kicked], channel: Lane }) {}
|
|
215
|
+
const WorkLive = Procedure.live(Work, function* () {
|
|
216
|
+
yield* tick(5)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const TestLayer = Layer.mergeAll(Channel.live(Lane), WorkLive).pipe(
|
|
220
|
+
Layer.provideMerge(Engine),
|
|
221
|
+
Layer.locally(CurrentInstrumentation, recorder.instrumentation),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return Effect.gen(function* () {
|
|
225
|
+
yield* publish('Normal', Event.construct(Kicked, {}))
|
|
226
|
+
yield* tick(40)
|
|
227
|
+
expect(recorder.procedures).toEqual([
|
|
228
|
+
{ name: 'InstrWork', channel: 'InstrLane', eventTag: 'InstrKicked' },
|
|
229
|
+
])
|
|
230
|
+
expect(recorder.ends.procedure).toBe(1)
|
|
231
|
+
}).pipe(Effect.provide(TestLayer))
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ── The scene seam: `profiledScene` must reach layers inside a CLOSED scene and
|
|
235
|
+
// carry the handle for hosts, without leaking into the original scene. ─────────
|
|
236
|
+
|
|
237
|
+
class SceneCount extends State.make('instr-scene-count', S.Number) {}
|
|
238
|
+
class SceneStates extends StateGroup.make(SceneCount) {}
|
|
239
|
+
class SceneBumped extends Event.make('InstrSceneBumped', S.Struct({})) {}
|
|
240
|
+
class SceneBump extends Reducer.make('InstrSceneBump', {
|
|
241
|
+
states: [SceneCount],
|
|
242
|
+
events: [SceneBumped],
|
|
243
|
+
}) {}
|
|
244
|
+
const SceneBumpLive = Reducer.live(SceneBump, (n) => n + 1)
|
|
245
|
+
|
|
246
|
+
class SceneUi extends ui('InstrScene')<{ props: { count: number } }>() {}
|
|
247
|
+
class SceneRoot extends Composition.make('InstrScene', {
|
|
248
|
+
title: 'InstrScene',
|
|
249
|
+
states: [SceneStates],
|
|
250
|
+
events: [SceneBumped],
|
|
251
|
+
ui: SceneUi,
|
|
252
|
+
}) {}
|
|
253
|
+
const SceneRootLive = Composition.live(SceneRoot, function* () {
|
|
254
|
+
const count = yield* StateGroup.select(SceneStates, 'instr-scene-count')
|
|
255
|
+
return mount({ props: { count }, slots: {} })
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const SceneApp = SceneRootLive.pipe(
|
|
259
|
+
Layer.provideMerge(provide(SceneUi, Ui.make(SceneUi, () => null))),
|
|
260
|
+
Layer.provideMerge(
|
|
261
|
+
Layer.mergeAll(SceneBumpLive).pipe(
|
|
262
|
+
Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(SceneStates, { 'instr-scene-count': 0 }))),
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
const InstrScene = scene(SceneRoot, { provide: [SceneApp] })
|
|
267
|
+
|
|
268
|
+
const runScene = (target: typeof InstrScene) => {
|
|
269
|
+
const layer = target.provide.reduce((a, b) => Layer.merge(a, b))
|
|
270
|
+
const runtime = ManagedRuntime.make(layer)
|
|
271
|
+
const drive = Effect.gen(function* () {
|
|
272
|
+
yield* publish('High', Event.construct(SceneBumped, {}))
|
|
273
|
+
yield* tick()
|
|
274
|
+
})
|
|
275
|
+
return { runtime, drive }
|
|
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)
|
|
282
|
+
|
|
283
|
+
const { runtime, drive } = runScene(profiled)
|
|
284
|
+
try {
|
|
285
|
+
await runtime.runPromise(drive)
|
|
286
|
+
expect(recorder.reducers).toEqual([{ name: 'InstrSceneBump', eventTag: 'InstrSceneBumped' }])
|
|
287
|
+
expect(recorder.states).toEqual(['instr-scene-count'])
|
|
288
|
+
} finally {
|
|
289
|
+
await runtime.dispose()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Building the ORIGINAL scene again records nothing — the overlay lives only
|
|
293
|
+
// on the profiled variant's wrapped layers.
|
|
294
|
+
const before = recorder.reducers.length
|
|
295
|
+
expect(sceneInstrumentation(InstrScene)).toBe(noopInstrumentation)
|
|
296
|
+
const original = runScene(InstrScene)
|
|
297
|
+
try {
|
|
298
|
+
await original.runtime.runPromise(original.drive)
|
|
299
|
+
expect(recorder.reducers.length).toBe(before)
|
|
300
|
+
} finally {
|
|
301
|
+
await original.runtime.dispose()
|
|
302
|
+
}
|
|
303
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Effect, FiberRef, GlobalValue } from 'effect'
|
|
2
|
+
import type { Priority } from './bus'
|
|
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
|
+
export type SpanEnd = () => void
|
|
19
|
+
|
|
20
|
+
export interface Instrumentation {
|
|
21
|
+
/** An event entered the drain frame (one call per envelope, per frame). */
|
|
22
|
+
eventDispatched(tag: string, priority: Priority): void
|
|
23
|
+
/** A reducer is folding one event; ends when its synchronous `apply` returns. */
|
|
24
|
+
reducerRun(name: string, eventTag: string): SpanEnd
|
|
25
|
+
/** A store accepted a genuinely new value (post-`Equal` gate). */
|
|
26
|
+
stateUpdated(name: string): void
|
|
27
|
+
/** A calc's projection actually ran (memo hits are not counted). */
|
|
28
|
+
calcRecomputed(name: string): SpanEnd
|
|
29
|
+
/** An async calc's query effect started; ends when it resolves/fails/cancels. */
|
|
30
|
+
queryRun(name: string): SpanEnd
|
|
31
|
+
/** A procedure body started on its channel; ends when the fiber completes. */
|
|
32
|
+
procedureRun(name: string, channel: string, eventTag: string): SpanEnd
|
|
33
|
+
/** A composition rendered one frame (host-injected: React `Compose` / proof engine). */
|
|
34
|
+
uiRendered(composition: string): SpanEnd
|
|
35
|
+
/** One drain tick: folds + routing for `eventCount` envelopes. */
|
|
36
|
+
frame(eventCount: number): SpanEnd
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const noopSpanEnd: SpanEnd = () => {}
|
|
40
|
+
|
|
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
|
+
export const noopInstrumentation: Instrumentation = {
|
|
47
|
+
eventDispatched: () => {},
|
|
48
|
+
reducerRun: () => noopSpanEnd,
|
|
49
|
+
stateUpdated: () => {},
|
|
50
|
+
calcRecomputed: () => noopSpanEnd,
|
|
51
|
+
queryRun: () => noopSpanEnd,
|
|
52
|
+
procedureRun: () => noopSpanEnd,
|
|
53
|
+
uiRendered: () => noopSpanEnd,
|
|
54
|
+
frame: () => noopSpanEnd,
|
|
55
|
+
}
|
|
56
|
+
|
|
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
|
+
export const CurrentInstrumentation: FiberRef.FiberRef<Instrumentation> =
|
|
71
|
+
GlobalValue.globalValue(Symbol.for('reform/CurrentInstrumentation'), () =>
|
|
72
|
+
FiberRef.unsafeMake<Instrumentation>(noopInstrumentation),
|
|
73
|
+
)
|
|
74
|
+
|
|
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
|
+
export const resolveInstrumentation: Effect.Effect<Instrumentation> =
|
|
80
|
+
FiberRef.get(CurrentInstrumentation)
|
|
81
|
+
|
|
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
|
+
export const stateUpdateHook = (
|
|
88
|
+
instrumentation: Instrumentation,
|
|
89
|
+
name: string,
|
|
90
|
+
): (() => void) =>
|
|
91
|
+
instrumentation === noopInstrumentation
|
|
92
|
+
? noopSpanEnd
|
|
93
|
+
: () => instrumentation.stateUpdated(name)
|
package/src/runtime/loop.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from '../channel/channel'
|
|
8
8
|
import { notificationsLayer } from '../internal/scheduler'
|
|
9
9
|
import { Bus, busLayer, type Envelope, type Tagged } from './bus'
|
|
10
|
+
import { type Instrumentation, resolveInstrumentation } from './instrumentation'
|
|
10
11
|
import { Queries, queriesLayer } from './queries'
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -94,16 +95,21 @@ const bucketOf = <V>(map: Map<string, Array<V>>, tag: string): Array<V> =>
|
|
|
94
95
|
const isolateApply = (
|
|
95
96
|
reducer: ReducerEntry,
|
|
96
97
|
event: Tagged,
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
instrumentation: Instrumentation,
|
|
99
|
+
): Option.Option<{ readonly event: Tagged; readonly error: unknown }> => {
|
|
100
|
+
const end = instrumentation.reducerRun(reducer.name, event._tag)
|
|
101
|
+
return Effect.runSync(
|
|
99
102
|
Effect.match(
|
|
100
|
-
Effect.try({ try: () => reducer.apply(event), catch: (error) => error })
|
|
103
|
+
Effect.try({ try: () => reducer.apply(event), catch: (error) => error }).pipe(
|
|
104
|
+
Effect.ensuring(Effect.sync(end)),
|
|
105
|
+
),
|
|
101
106
|
{
|
|
102
107
|
onFailure: (error) => Option.some({ event, error }),
|
|
103
108
|
onSuccess: () => Option.none(),
|
|
104
109
|
},
|
|
105
110
|
),
|
|
106
111
|
)
|
|
112
|
+
}
|
|
107
113
|
|
|
108
114
|
export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
|
|
109
115
|
|
|
@@ -119,6 +125,8 @@ const drain = Effect.gen(function* () {
|
|
|
119
125
|
const reducers = yield* Reducers
|
|
120
126
|
const { byName: channels } = yield* Channels
|
|
121
127
|
const procedures = yield* Procedures
|
|
128
|
+
// Captured once at engine build; the noop instance costs nothing per frame.
|
|
129
|
+
const instrumentation = yield* resolveInstrumentation
|
|
122
130
|
const subscription = yield* PubSub.subscribe(bus)
|
|
123
131
|
|
|
124
132
|
yield* Effect.forkScoped(
|
|
@@ -141,12 +149,16 @@ const drain = Effect.gen(function* () {
|
|
|
141
149
|
// procedure bodies (run later, off their channel fibers) see post-batch
|
|
142
150
|
// state. No `yield*` in between, or the flush could fire early.
|
|
143
151
|
const failures = yield* Effect.sync(() => {
|
|
152
|
+
const endFrame = instrumentation.frame(ordered.length)
|
|
153
|
+
ordered.forEach((envelope) =>
|
|
154
|
+
instrumentation.eventDispatched(envelope.event._tag, envelope.priority),
|
|
155
|
+
)
|
|
144
156
|
// Isolate each fold: a synchronous throw in one reducer must not abandon
|
|
145
157
|
// the rest of the frame nor (via the `forever` below) kill the drain fiber
|
|
146
158
|
// and freeze the whole app. Collect failures here, log after the block.
|
|
147
159
|
const collected = Arr.flatMap(ordered, (envelope) =>
|
|
148
160
|
Arr.filterMap(bucketOf(reducers.byTag, envelope.event._tag), (reducer) =>
|
|
149
|
-
isolateApply(reducer, envelope.event),
|
|
161
|
+
isolateApply(reducer, envelope.event, instrumentation),
|
|
150
162
|
),
|
|
151
163
|
)
|
|
152
164
|
// Offer to each DISTINCT channel once. Routing per-procedure would offer a
|
|
@@ -157,6 +169,7 @@ const drain = Effect.gen(function* () {
|
|
|
157
169
|
channels.get(channelName)?.offer(envelope.event)
|
|
158
170
|
})
|
|
159
171
|
})
|
|
172
|
+
endFrame()
|
|
160
173
|
return collected
|
|
161
174
|
})
|
|
162
175
|
yield* Effect.forEach(
|
package/src/scene/scene.ts
CHANGED
|
@@ -5,6 +5,11 @@ import type { EventOf } from '../event/event'
|
|
|
5
5
|
import { CurrentSeedOverrides } from '../internal/seeds'
|
|
6
6
|
import type { SeedsOf } from '../state/stateGroup'
|
|
7
7
|
import type { Bus } from '../runtime/bus'
|
|
8
|
+
import {
|
|
9
|
+
CurrentInstrumentation,
|
|
10
|
+
type Instrumentation,
|
|
11
|
+
noopInstrumentation,
|
|
12
|
+
} from '../runtime/instrumentation'
|
|
8
13
|
|
|
9
14
|
// A scene is a VALUE — a composition plus the closed wiring that runs it: the
|
|
10
15
|
// layers that supply its logic/views (each seeding its own live state) and the
|
|
@@ -43,6 +48,14 @@ export interface Scene<
|
|
|
43
48
|
/** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
|
|
44
49
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional boot read as `scene.boot ?? []` across non-batch hosts (drive/proof/remote/react/editor); Option would break them
|
|
45
50
|
readonly boot?: ReadonlyArray<BootEvent>
|
|
51
|
+
/**
|
|
52
|
+
* The profiler hooks this scene records into, when profiled (`profiledScene`).
|
|
53
|
+
* Hosts read it via {@link sceneInstrumentation} to time renders — kept on the
|
|
54
|
+
* scene (not only inside the layers) because render spans live in the HOST
|
|
55
|
+
* (React `Compose`, the proof engine), outside the runtime's layer context.
|
|
56
|
+
*/
|
|
57
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional carrier like `boot`; absent on every unprofiled scene, so Option would tax all authoring call sites
|
|
58
|
+
readonly instrumentation?: Instrumentation
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
/** Closed wiring + optional boot events handed to {@link scene}. */
|
|
@@ -88,6 +101,27 @@ export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>
|
|
|
88
101
|
? base
|
|
89
102
|
: { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
|
|
90
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Overlay profiler hooks onto a CLOSED scene — the profiling analog of
|
|
106
|
+
* {@link seedScene}. Wraps each provided layer with
|
|
107
|
+
* `Layer.locally(CurrentInstrumentation, instr)` so engine/state/calc layers
|
|
108
|
+
* capture `instr` at build time, and carries `instr` on the scene so hosts can
|
|
109
|
+
* time renders. Scenes stay closed; production wiring never calls this —
|
|
110
|
+
* `@playfast/reform-profiler`'s `profileScene` is the intended caller.
|
|
111
|
+
*/
|
|
112
|
+
export const profiledScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
113
|
+
base: Scene<C, S>,
|
|
114
|
+
instrumentation: Instrumentation,
|
|
115
|
+
): Scene<C, S> => ({
|
|
116
|
+
...base,
|
|
117
|
+
instrumentation,
|
|
118
|
+
provide: base.provide.map(Layer.locally(CurrentInstrumentation, instrumentation)),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/** The instrumentation a host records render spans into — noop when unprofiled. */
|
|
122
|
+
export const sceneInstrumentation = (candidate: Scene): Instrumentation =>
|
|
123
|
+
candidate.instrumentation ?? noopInstrumentation
|
|
124
|
+
|
|
91
125
|
/** Reflection guard: is this exported value a scene? */
|
|
92
126
|
export const isScene = (candidate: unknown): candidate is Scene =>
|
|
93
127
|
typeof candidate === 'object' &&
|
package/src/state/state.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Context, Effect, FiberRef, Layer, Option, Schema } from 'effect'
|
|
|
2
2
|
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
3
3
|
import { resolveScheduler } from '../internal/scheduler'
|
|
4
4
|
import { CurrentSeedOverrides } from '../internal/seeds'
|
|
5
|
+
import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
|
|
5
6
|
import { claimStateTag } from '../internal/stateRegistry'
|
|
6
7
|
import { makeStore, type Store } from '../internal/store'
|
|
7
8
|
import { readTracked } from '../internal/track'
|
|
@@ -89,7 +90,9 @@ export const live = <S extends AnyState>(
|
|
|
89
90
|
): Layer.Layer<Store<StateValue<S>>> =>
|
|
90
91
|
Layer.effect(
|
|
91
92
|
state.store,
|
|
92
|
-
Effect.
|
|
93
|
-
|
|
93
|
+
Effect.all([resolveScheduler, resolveSeed(state, initial), resolveInstrumentation]).pipe(
|
|
94
|
+
Effect.map(([scheduler, seed, instrumentation]) =>
|
|
95
|
+
makeStore(seed, scheduler, stateUpdateHook(instrumentation, state.manifest.name)),
|
|
96
|
+
),
|
|
94
97
|
),
|
|
95
98
|
)
|
package/src/state/stateFamily.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Context, Effect, Layer, Option, type Schema } from 'effect'
|
|
|
2
2
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
3
|
import { resolveScheduler, type Scheduler } from '../internal/scheduler'
|
|
4
4
|
import { claimStateTag } from '../internal/stateRegistry'
|
|
5
|
+
import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
|
|
5
6
|
import { makeStore, type Store } from '../internal/store'
|
|
6
7
|
import { readTracked } from '../internal/track'
|
|
7
8
|
|
|
@@ -50,6 +51,7 @@ const makeFamilyStore = <K, V>(
|
|
|
50
51
|
seed: (key: K) => V,
|
|
51
52
|
scheduler: Scheduler,
|
|
52
53
|
options: FamilyOptions = {},
|
|
54
|
+
onChange: () => void = () => {},
|
|
53
55
|
): FamilyStore<K, V> => {
|
|
54
56
|
const entries = new Map<K, Store<V>>()
|
|
55
57
|
const evictWhenUnused = options.evictWhenUnused === true
|
|
@@ -94,7 +96,7 @@ const makeFamilyStore = <K, V>(
|
|
|
94
96
|
if (existing !== undefined) {
|
|
95
97
|
return existing
|
|
96
98
|
}
|
|
97
|
-
const base = makeStore(seed(key), scheduler)
|
|
99
|
+
const base = makeStore(seed(key), scheduler, onChange)
|
|
98
100
|
const created = evictWhenUnused ? refCounted(key, base) : base
|
|
99
101
|
entries.set(key, created)
|
|
100
102
|
return created
|
|
@@ -185,6 +187,13 @@ export const live = <N extends string, K, V>(
|
|
|
185
187
|
const seed: (key: K) => V = typeof initial === 'function' ? (initial as (key: K) => V) : () => initial
|
|
186
188
|
return Layer.effect(
|
|
187
189
|
family.store,
|
|
188
|
-
Effect.
|
|
190
|
+
Effect.zipWith(resolveScheduler, resolveInstrumentation, (scheduler, instrumentation) =>
|
|
191
|
+
makeFamilyStore<K, V>(
|
|
192
|
+
seed,
|
|
193
|
+
scheduler,
|
|
194
|
+
options,
|
|
195
|
+
stateUpdateHook(instrumentation, family.manifest.name),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
189
198
|
)
|
|
190
199
|
}
|