@playfast/reform 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -1,13 +1,8 @@
1
- // Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
2
- // These guard the cast-free seam: if a guarantee regresses, a `@ts-expect-error`
3
- // goes unused or an assignment fails, and `tsc` breaks.
4
-
5
1
  import { Context, Effect, Layer, Match, Schema as S } from 'effect'
6
2
  import * as Boundary from '../boundary/boundary'
7
3
  import type { AsyncData } from '../calc/asyncData'
8
4
  import { type Channels, type Procedures } from '../channel/channel'
9
5
  import * as Event from '../event/event'
10
- import type { Store } from '../internal/store'
11
6
  import * as Reducer from '../reducer/reducer'
12
7
  import type { Bus } from '../runtime/bus'
13
8
  import { type Reducers } from '../runtime/loop'
@@ -16,8 +11,10 @@ import * as StateGroup from '../state/stateGroup'
16
11
  import { type AnySource, type Source } from '../state/token'
17
12
  import * as RemoteState from './remoteState'
18
13
 
19
- const AddIntentBase: Event.EventClass<'TAddIntent', { readonly id: string; readonly name: string }> =
20
- Event.make('TAddIntent', S.Struct({ id: S.String, name: S.String }))
14
+ const AddIntentBase: Event.EventClass<
15
+ 'TAddIntent',
16
+ { readonly id: string; readonly name: string }
17
+ > = Event.make('TAddIntent', S.Struct({ id: S.String, name: S.String }))
21
18
  class AddIntent extends AddIntentBase {}
22
19
  const RenameIntentBase: Event.EventClass<
23
20
  'TRenameIntent',
@@ -25,15 +22,16 @@ const RenameIntentBase: Event.EventClass<
25
22
  > = Event.make('TRenameIntent', S.Struct({ id: S.String, name: S.String }))
26
23
  class RenameIntent extends RenameIntentBase {}
27
24
  type Intent = Event.EventType<typeof AddIntent> | Event.EventType<typeof RenameIntent>
28
- // An undeclared intent tag, named so the negative `apply` below avoids an inline
29
- // object-type parameter.
30
25
  type DeleteIntentTag = { readonly _tag: 'TDeleteIntent' }
31
26
 
32
27
  const PageBase: State.StateClass<'tcPage', number> = State.make('tcPage', S.Number)
33
28
  class Page extends PageBase {}
34
29
  const InputsBase: StateGroup.StateGroupClass<readonly [typeof Page]> = StateGroup.make(Page)
35
30
  class Inputs extends InputsBase {}
36
- const page: Source<'tcPage', number> = StateGroup.select(Inputs, 'tcPage')
31
+ const page: Source<'tcPage', number, State.StateStore<'tcPage', number>> = StateGroup.select(
32
+ Inputs,
33
+ 'tcPage',
34
+ )
37
35
 
38
36
  const Row: S.Struct<{ id: typeof S.String; name: typeof S.String }> = S.Struct({
39
37
  id: S.String,
@@ -63,15 +61,12 @@ const ItemsBase: RemoteState.RemoteStateClass<
63
61
  })
64
62
  class Items extends ItemsBase {}
65
63
 
66
- // --- intent-union inference: apply/send receive exactly the declared union ---
67
-
68
- // POSITIVE: a Match.exhaustive fold over the declared intents compiles, `send`'s
69
- // R2 flows from its body, and gated `disabled` is accepted.
70
64
  export const okLive: Layer.Layer<
71
- | Store<AsyncData<ReadonlyArray<Row>, string, true>>
72
- | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
65
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
66
+ | RemoteState.RemoteStateTruthStore<'TItems'>
67
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
73
68
  never,
74
- Store<number> | SendDep | Reducers | Procedures | Channels | Bus
69
+ State.StateStore<'tcPage', number> | SendDep | Reducers | Procedures | Channels | Bus
75
70
  > = RemoteState.live(Items, {
76
71
  query: ({ tcPage }) =>
77
72
  tcPage > 0 ? Effect.succeed<ReadonlyArray<Row>>([]) : Effect.fail('empty page'),
@@ -87,12 +82,12 @@ export const okLive: Layer.Layer<
87
82
  disabled: ({ tcPage }) => tcPage < 0,
88
83
  })
89
84
 
90
- // NEGATIVE: a handler typed for an undeclared tag does not fit the union.
91
85
  export const badIntent: Layer.Layer<
92
- | Store<AsyncData<ReadonlyArray<Row>, string, true>>
93
- | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
86
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
87
+ | RemoteState.RemoteStateTruthStore<'TItems'>
88
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
94
89
  never,
95
- Store<number> | Reducers | Procedures | Channels | Bus
90
+ State.StateStore<'tcPage', number> | Reducers | Procedures | Channels | Bus
96
91
  > = RemoteState.live(Items, {
97
92
  query: () => Effect.succeed<ReadonlyArray<Row>>([]),
98
93
  send: () => Effect.void,
@@ -100,27 +95,23 @@ export const badIntent: Layer.Layer<
100
95
  apply: (rows: ReadonlyArray<Row>, intent: DeleteIntentTag) => rows,
101
96
  })
102
97
 
103
- // --- layer requirement honesty: RIn is complete and exact ---
104
-
105
- // POSITIVE: the layer's RIn is exactly the input stores, the query/send context,
106
- // and the engine registries — and its ROut names the overlay + pending stores.
107
98
  export const okLayer: Layer.Layer<
108
- Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
99
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
100
+ | RemoteState.RemoteStateTruthStore<'TItems'>
101
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
109
102
  never,
110
- Store<number> | SendDep | Reducers | Procedures | Channels | Bus
103
+ State.StateStore<'tcPage', number> | SendDep | Reducers | Procedures | Channels | Bus
111
104
  > = okLive
112
105
 
113
- // NEGATIVE: dropping the send dependency (R2) from the annotation fails —
114
- // requirements cannot be silently widened away.
115
106
  // @ts-expect-error the send body's R2 must appear in the layer's requirements
116
107
  export const badLayer: Layer.Layer<
117
- Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
108
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
109
+ | RemoteState.RemoteStateTruthStore<'TItems'>
110
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
118
111
  never,
119
- Store<number> | Reducers | Procedures | Channels | Bus
112
+ State.StateStore<'tcPage', number> | Reducers | Procedures | Channels | Bus
120
113
  > = okLive
121
114
 
122
- // --- alwaysOn / error narrowing: the read carries exactly the possible arms ---
123
-
124
115
  const AlwaysItemsBase: RemoteState.RemoteStateClass<
125
116
  'TAlwaysItems',
126
117
  readonly [typeof page],
@@ -136,12 +127,12 @@ const AlwaysItemsBase: RemoteState.RemoteStateClass<
136
127
  })
137
128
  class AlwaysItems extends AlwaysItemsBase {}
138
129
 
139
- // NEGATIVE: an alwaysOn remote state rejects `disabled` (erased to never).
140
130
  export const badDisabled: Layer.Layer<
141
- | Store<AsyncData<ReadonlyArray<Row>, never, false>>
142
- | Store<ReadonlyArray<RemoteState.PendingIntent<Event.EventType<typeof AddIntent>>>>,
131
+ | RemoteState.RemoteStateVisibleStore<'TAlwaysItems'>
132
+ | RemoteState.RemoteStateTruthStore<'TAlwaysItems'>
133
+ | RemoteState.RemoteStatePendingStore<'TAlwaysItems'>,
143
134
  never,
144
- Store<number> | Reducers | Procedures | Channels | Bus
135
+ State.StateStore<'tcPage', number> | Reducers | Procedures | Channels | Bus
145
136
  > = RemoteState.live(AlwaysItems, {
146
137
  query: () => Effect.succeed<ReadonlyArray<Row>>([]),
147
138
  send: () => Effect.void,
@@ -156,41 +147,41 @@ declare const idleArm: 'Idle'
156
147
  declare const errorArm: 'Error'
157
148
  declare const fourArms: 'Idle' | 'Loading' | 'Success' | 'Error'
158
149
 
159
- // POSITIVE: alwaysOn + no `error` schema ⇒ at most Loading | Success…
160
150
  export const alwaysExact: 'Loading' | 'Success' = alwaysArms
161
- // …and gated + `error` schema ⇒ exactly all four arms (both directions).
162
151
  export const gatedSubset: 'Idle' | 'Loading' | 'Success' | 'Error' = gatedArms
163
152
  export const gatedAll: Effect.Effect.Success<typeof Items>['_tag'] = fourArms
164
153
 
165
- // NEGATIVE: the excluded arms really are gone from the read type.
166
154
  // @ts-expect-error an alwaysOn remote state has no Idle arm
167
155
  export const noIdle: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = idleArm
168
156
  // @ts-expect-error no `error` schema ⇒ no Error arm
169
157
  export const noError: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = errorArm
170
158
 
171
- // --- surface shape: Sources where expected, never a reducer target ---
172
-
173
- // POSITIVE: the class itself is a `Source` (a calc input / boundary coverage),
174
- // valued at the overlaid lifecycle.
175
- export const classAsSource: Source<'TItems', AsyncData<ReadonlyArray<Row>, string, true>> = Items
159
+ export const classAsSource: Source<
160
+ 'TItems',
161
+ AsyncData<ReadonlyArray<Row>, string, true>,
162
+ RemoteState.RemoteStateVisibleStore<'TItems'>
163
+ > = Items
176
164
  export const classAsAnySource: AnySource = Items
177
165
 
178
- // POSITIVE: `pending` is an ordinary read-only `Source` (badges, "saving…" chrome).
179
- export const pendingAsSource: Source<'TItems/pending', ReadonlyArray<RemoteState.PendingIntent<Intent>>> =
180
- Items.pending
166
+ export const pendingAsSource: Source<
167
+ 'TItems/pending',
168
+ ReadonlyArray<RemoteState.PendingIntent<Intent>>,
169
+ RemoteState.RemoteStatePendingStore<'TItems'>
170
+ > = Items.pending
181
171
 
182
- // POSITIVE: `truth` is the un-overlaid lifecycle under the same arms.
183
- export const truthAsSource: Source<'TItems/truth', AsyncData<ReadonlyArray<Row>, string, true>> =
184
- Items.truth
172
+ export const truthAsSource: Source<
173
+ 'TItems/truth',
174
+ AsyncData<ReadonlyArray<Row>, string, true>,
175
+ RemoteState.RemoteStateTruthStore<'TItems'>
176
+ > = Items.truth
185
177
 
186
- // POSITIVE: a boundary covers the remote state like any lifecycle source.
187
178
  export const covered: Boundary.BoundaryClass<'TItemsBoundary', readonly [typeof Items]> =
188
179
  Boundary.make('TItemsBoundary', { over: [Items] })
189
180
 
190
- // NEGATIVE: the pending queue is NOT user state — `Reducer.make` cannot target
191
- // it (it is a `StateToken`, not a `StateClass`; the hidden queue reducer is the
192
- // sole writer).
193
- export const badReducerTarget: Reducer.StateReducerClass<State.AnyState, readonly [typeof AddIntent]> =
181
+ export const badReducerTarget: Reducer.StateReducerClass<
182
+ State.AnyState,
183
+ readonly [typeof AddIntent]
184
+ > =
194
185
  // @ts-expect-error the pending queue is not a reducible state
195
186
  Reducer.make('TBadReducer', {
196
187
  states: [Items.pending],
@@ -0,0 +1,100 @@
1
+ import { Context, Duration, Effect, Layer, Option, Schema } from 'effect'
2
+ import { expect, it } from '@effect/vitest'
3
+ import * as Composition from '../compose/composition'
4
+ import { mount } from '../compose/structure'
5
+ import { ui } from '../compose/ui'
6
+ import { scene } from '../scene/scene'
7
+ import { Engine } from './loop'
8
+ import { makeAppRuntime } from './appRuntime'
9
+
10
+ interface ActivationProbeService {
11
+ readonly acquired: Array<string>
12
+ readonly released: Array<string>
13
+ }
14
+
15
+ class ActivationProbe extends Context.Tag('appRuntime.ActivationProbe')<
16
+ ActivationProbe,
17
+ ActivationProbeService
18
+ >() {}
19
+
20
+ class ScopedUi extends ui('appRuntime.ScopedUi')<{
21
+ props: { readonly id: string }
22
+ }>() {}
23
+
24
+ class ScopedComposition extends Composition.make('appRuntime.Scoped', {
25
+ title: 'Scoped',
26
+ props: Schema.Struct({ id: Schema.String }),
27
+ ui: ScopedUi,
28
+ })<ScopedComposition>() {}
29
+
30
+ const ScopedLive = Composition.liveScoped(ScopedComposition, {
31
+ render: function* () {
32
+ const { id } = yield* ScopedComposition.props
33
+ return mount({ props: { id }, slots: {} })
34
+ },
35
+ activate: function* () {
36
+ const { id } = yield* ScopedComposition.props
37
+ const probe = yield* ActivationProbe
38
+ yield* Effect.acquireRelease(
39
+ Effect.sync(() => probe.acquired.push(id)),
40
+ () => Effect.sync(() => probe.released.push(id)),
41
+ )
42
+ },
43
+ })
44
+
45
+ class PlainUi extends ui('appRuntime.PlainUi')<{ props: {} }>() {}
46
+ class PlainComposition extends Composition.make('appRuntime.Plain', {
47
+ title: 'Plain',
48
+ ui: PlainUi,
49
+ })<PlainComposition>() {}
50
+
51
+ const PlainLive = Composition.live(PlainComposition, function* () {
52
+ return mount({ props: {}, slots: {} })
53
+ })
54
+
55
+ const makeScene = (probe: ActivationProbeService) =>
56
+ scene(ScopedComposition, {
57
+ provide: [
58
+ Layer.mergeAll(ScopedLive, PlainLive).pipe(
59
+ Layer.provideMerge(Layer.succeed(ActivationProbe, probe)),
60
+ Layer.provideMerge(Engine),
61
+ ),
62
+ ],
63
+ })
64
+
65
+ const settle = Effect.sleep(Duration.millis(50))
66
+
67
+ it.scopedLive(
68
+ 'keeps activation lazy, scopes sibling mounts independently, and closes them at teardown',
69
+ () =>
70
+ Effect.gen(function* () {
71
+ const probe: ActivationProbeService = { acquired: [], released: [] }
72
+ const app = makeAppRuntime(makeScene(probe))
73
+ yield* Effect.addFinalizer(() => Effect.sync(() => app.dispose()))
74
+ const service = app.read(ScopedComposition.tag)
75
+ const plain = app.read(PlainComposition.tag)
76
+
77
+ yield* Composition.render(service, {
78
+ props: { id: 'first' },
79
+ tracker: { add: () => undefined },
80
+ })
81
+ expect(probe.acquired).toEqual([])
82
+ expect(Option.isNone(Composition.activation(plain, {}))).toBe(true)
83
+
84
+ const first = app.mountScoped(
85
+ Option.getOrThrow(Composition.activation(service, { id: 'first' })),
86
+ )
87
+ app.mountScoped(Option.getOrThrow(Composition.activation(service, { id: 'second' })))
88
+ yield* settle
89
+ expect(probe.acquired).toEqual(['first', 'second'])
90
+
91
+ first()
92
+ first()
93
+ yield* settle
94
+ expect(probe.released).toEqual(['first'])
95
+
96
+ app.dispose()
97
+ yield* settle
98
+ expect(probe.released).toEqual(['first', 'second'])
99
+ }),
100
+ )
@@ -0,0 +1,249 @@
1
+ import { Context, Duration, Effect, Layer, Option, Schema as S } from 'effect'
2
+ import { expect, it } from '@effect/vitest'
3
+ import * as Composition from '../compose/composition'
4
+ import { provide } from '../compose/provide'
5
+ import { mount } from '../compose/structure'
6
+ import * as Ui from '../compose/ui'
7
+ import { ui } from '../compose/ui'
8
+ import * as Event from '../event/event'
9
+ import * as Feature from '../feature/feature'
10
+ import { featureModule } from '../feature/feature'
11
+ import * as Reducer from '../reducer/reducer'
12
+ import { scene } from '../scene/scene'
13
+ import * as State from '../state/state'
14
+ import { Engine, Reducers } from './loop'
15
+ import { makeAppRuntime } from './appRuntime'
16
+
17
+ // The host-level mount lifecycle: `RuntimeHandle.mountFeature` forks the mount,
18
+ // and its disposer must CANCEL a mount whose fiber has not run yet (StrictMode
19
+ // cleanup/setup, fast remounts) — a scope close alone does not interrupt, so a
20
+ // stale mount would still register reducers and dispatch `binding.boot` onto the
21
+ // shared bus.
22
+
23
+ class Tick extends Event.make('appRuntime.Tick', S.Struct({})) {}
24
+ class Count extends State.make('appRuntime.count', S.Number) {}
25
+ class CountReducer extends Reducer.make('appRuntime.CountReducer', {
26
+ states: [Count],
27
+ events: [Tick],
28
+ }) {}
29
+
30
+ class CtrUi extends ui('appRuntime.CtrUi')<{ props: {} }>() {}
31
+ class CtrComp extends Composition.make('appRuntime.CtrComp', {
32
+ title: 'Counter',
33
+ ui: CtrUi,
34
+ })<CtrComp>() {}
35
+ const CtrLive = Composition.live(CtrComp, function* () {
36
+ return mount({ props: {}, slots: {}, events: {} })
37
+ })
38
+
39
+ class CounterFeature extends Feature.make('appRuntime.counter', {
40
+ composition: CtrComp,
41
+ module: featureModule(
42
+ [],
43
+ Layer.merge(
44
+ CtrLive,
45
+ Reducer.live(CountReducer, (n) => n + 1).pipe(Layer.provideMerge(State.live(Count, 0))),
46
+ ),
47
+ ),
48
+ boot: [Event.construct(Tick, {})],
49
+ }) {}
50
+
51
+ class RootUi extends ui('appRuntime.RootUi')<{ props: {} }>() {}
52
+ class RootComp extends Composition.make('appRuntime.RootComp', {
53
+ title: 'Root',
54
+ ui: RootUi,
55
+ })<RootComp>() {}
56
+ const RootLive = Composition.live(RootComp, function* () {
57
+ return mount({ props: {}, slots: {}, events: {} })
58
+ })
59
+
60
+ const rootScene = () =>
61
+ scene(RootComp, {
62
+ provide: [
63
+ RootLive.pipe(
64
+ Layer.provideMerge(
65
+ provide(
66
+ RootUi,
67
+ Ui.make(RootUi, () => null),
68
+ ),
69
+ ),
70
+ Layer.provideMerge(Engine),
71
+ ),
72
+ ],
73
+ })
74
+
75
+ const settle = Effect.sleep(Duration.millis(50))
76
+ const appRuntime = Effect.acquireRelease(
77
+ Effect.sync(() => makeAppRuntime(rootScene())),
78
+ (app) => Effect.sync(() => app.dispose()),
79
+ )
80
+
81
+ interface ParentValueService {
82
+ readonly value: string
83
+ }
84
+ const ParentValueBase: Context.TagClass<ParentValue, 'appRuntime.ParentValue', ParentValueService> =
85
+ Context.Tag('appRuntime.ParentValue')<ParentValue, ParentValueService>()
86
+ class ParentValue extends ParentValueBase {}
87
+
88
+ interface ChildValueService {
89
+ readonly value: string
90
+ }
91
+ const ChildValueBase: Context.TagClass<ChildValue, 'appRuntime.ChildValue', ChildValueService> =
92
+ Context.Tag('appRuntime.ChildValue')<ChildValue, ChildValueService>()
93
+ class ChildValue extends ChildValueBase {}
94
+
95
+ class ParentFeature extends Feature.make('appRuntime.parent', {
96
+ composition: CtrComp,
97
+ module: featureModule(
98
+ [],
99
+ Layer.merge(CtrLive, Layer.succeed(ParentValue, { value: 'from-parent' })),
100
+ ),
101
+ }) {}
102
+
103
+ class ChildFeature extends Feature.make('appRuntime.child', {
104
+ composition: CtrComp,
105
+ module: featureModule(
106
+ [ParentValue],
107
+ Layer.merge(
108
+ CtrLive,
109
+ Layer.effect(
110
+ ChildValue,
111
+ Effect.map(ParentValue, ({ value }) => ({ value })),
112
+ ),
113
+ ),
114
+ ),
115
+ }) {}
116
+
117
+ const CounterNative = Feature.tree<typeof CounterFeature>(CounterFeature.token)
118
+ const ParentNative = Feature.tree<typeof ParentFeature>(ParentFeature.token)
119
+ const ChildNative = Feature.tree<typeof ChildFeature>(ChildFeature.token)
120
+
121
+ it.scopedLive(
122
+ 'disposing a mount before its fiber runs cancels it — no registration, no boot, no handler calls',
123
+ () =>
124
+ Effect.gen(function* () {
125
+ const app = yield* appRuntime
126
+ const baseline = app.read(Reducers).entries.length
127
+ const calls = { live: 0, failed: 0 }
128
+ const dispose = app.mountFeature(CounterNative.binding, {
129
+ onLive: () => {
130
+ calls.live += 1
131
+ },
132
+ onFailed: () => {
133
+ calls.failed += 1
134
+ },
135
+ })
136
+ // Same-tick dispose (the StrictMode cleanup shape): the fork has not run yet.
137
+ dispose()
138
+ yield* settle
139
+ expect(calls).toEqual({ live: 0, failed: 0 })
140
+ expect(app.read(Reducers).entries.length).toBe(baseline)
141
+ expect(app.read(Reducers).byTag.get('appRuntime.Tick')).toBeUndefined()
142
+ }),
143
+ )
144
+
145
+ it.scopedLive(
146
+ 'an undisposed mount still goes live, and disposing it after live reclaims the registration',
147
+ () =>
148
+ Effect.gen(function* () {
149
+ const app = yield* appRuntime
150
+ const baseline = app.read(Reducers).entries.length
151
+ const calls = { live: 0, failed: 0 }
152
+ const dispose = app.mountFeature(CounterNative.binding, {
153
+ onLive: () => {
154
+ calls.live += 1
155
+ },
156
+ onFailed: () => {
157
+ calls.failed += 1
158
+ },
159
+ })
160
+ yield* Effect.sleep(Duration.millis(1)).pipe(
161
+ Effect.repeat({ until: () => calls.live === 1 }),
162
+ Effect.timeout(Duration.seconds(2)),
163
+ )
164
+ expect(calls).toEqual({ live: 1, failed: 0 })
165
+ expect(app.read(Reducers).entries.length).toBe(baseline + 1)
166
+ dispose()
167
+ yield* settle
168
+ expect(app.read(Reducers).entries.length).toBe(baseline)
169
+ }),
170
+ )
171
+
172
+ it.scopedLive('a nested Feature mounts against its immediate parent context', () =>
173
+ Effect.gen(function* () {
174
+ const app = yield* appRuntime
175
+ const observed: Array<string> = []
176
+ const disposers: Array<() => void> = []
177
+ yield* Effect.addFinalizer(() =>
178
+ Effect.sync(() => disposers.reverse().forEach((dispose) => dispose())),
179
+ )
180
+ disposers.push(
181
+ app.mountFeature(ParentNative.binding, {
182
+ onFailed: () => observed.push('parent-failed'),
183
+ onLive: (parent) => {
184
+ disposers.push(
185
+ parent.mountFeature(ChildNative.binding, {
186
+ onFailed: () => observed.push('child-failed'),
187
+ onLive: (child) => observed.push(child.read(ChildValue).value),
188
+ }),
189
+ )
190
+ },
191
+ }),
192
+ )
193
+ yield* Effect.sleep(Duration.millis(1)).pipe(
194
+ Effect.repeat({ until: () => observed.length > 0 }),
195
+ Effect.timeout(Duration.seconds(2)),
196
+ )
197
+ expect(observed).toEqual(['from-parent'])
198
+ }),
199
+ )
200
+
201
+ it.scopedLive(
202
+ 'an existential Feature binding validates its graph and exposes only safe optional reads',
203
+ () =>
204
+ Effect.gen(function* () {
205
+ const app = yield* appRuntime
206
+ const observed: Array<string> = []
207
+ const binding: Feature.AnyFeatureBinding = ParentNative.binding
208
+ const dispose = app.mountAnyFeature(binding, {
209
+ props: {},
210
+ onFailed: () => observed.push('failed'),
211
+ onLive: (child) => {
212
+ const service = child.readOption(ParentValue)
213
+ observed.push(
214
+ Option.match(service, {
215
+ onNone: () => 'missing',
216
+ onSome: ({ value }) => value,
217
+ }),
218
+ )
219
+ },
220
+ })
221
+ yield* Effect.addFinalizer(() => Effect.sync(dispose))
222
+ yield* Effect.sleep(Duration.millis(1)).pipe(
223
+ Effect.repeat({ until: () => observed.length > 0 }),
224
+ Effect.timeout(Duration.seconds(2)),
225
+ )
226
+ expect(observed).toEqual(['from-parent'])
227
+ }),
228
+ )
229
+
230
+ it.scopedLive(
231
+ 'an existential Feature binding fails before build when a requirement is absent',
232
+ () =>
233
+ Effect.gen(function* () {
234
+ const app = yield* appRuntime
235
+ const observed: Array<string> = []
236
+ const binding: Feature.AnyFeatureBinding = ChildNative.binding
237
+ const dispose = app.mountAnyFeature(binding, {
238
+ props: {},
239
+ onFailed: () => observed.push('failed'),
240
+ onLive: () => observed.push('live'),
241
+ })
242
+ yield* Effect.addFinalizer(() => Effect.sync(dispose))
243
+ yield* Effect.sleep(Duration.millis(1)).pipe(
244
+ Effect.repeat({ until: () => observed.length > 0 }),
245
+ Effect.timeout(Duration.seconds(2)),
246
+ )
247
+ expect(observed).toEqual(['failed'])
248
+ }),
249
+ )