@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
3
  "playbook": "./playbook",
4
- "version": "0.1.0",
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(recompute, sources.subscribe, scheduler)
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
  }),
@@ -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
@@ -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
- procedure.run(event).pipe(
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 { isScene, type MountedServices, type Scene, scene, seedScene } from './scene/scene'
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
- export { Engine, type ReducerEntry, Reducers } from './runtime/loop'
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
- options.query(request.args).pipe(
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
  ),
@@ -30,7 +30,14 @@ export interface Store<in out A> extends Inspectable {
30
30
  subscribe(listener: () => void): () => void
31
31
  }
32
32
 
33
- export const makeStore = <A>(initial: A, scheduler: Scheduler = defaultScheduler): Store<A> => {
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)
@@ -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
- ): Option.Option<{ readonly event: Tagged; readonly error: unknown }> =>
98
- Effect.runSync(
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(
@@ -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' &&
@@ -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.zipWith(resolveScheduler, resolveSeed(state, initial), (scheduler, seed) =>
93
- makeStore(seed, scheduler),
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
  )
@@ -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.map(resolveScheduler, (scheduler) => makeFamilyStore<K, V>(seed, scheduler, options)),
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
  }