@playfast/reform 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +62 -16
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +516 -18
  6. package/src/calc/asyncCalc.test.ts +207 -175
  7. package/src/calc/asyncCalc.ts +168 -39
  8. package/src/calc/calc.test.ts +3 -5
  9. package/src/calc/calc.ts +44 -18
  10. package/src/calc/calcFamily.test.ts +80 -22
  11. package/src/calc/calcFamily.ts +56 -25
  12. package/src/calc/compose.ts +1 -14
  13. package/src/channel/channel.ts +128 -11
  14. package/src/compose/composition.test.ts +19 -0
  15. package/src/compose/composition.ts +346 -62
  16. package/src/compose/props.ts +13 -6
  17. package/src/compose/provide.ts +187 -46
  18. package/src/compose/slot.ts +156 -19
  19. package/src/compose/structure.test.ts +6 -3
  20. package/src/compose/structure.ts +106 -13
  21. package/src/compose/ui.test.ts +19 -3
  22. package/src/compose/ui.ts +147 -54
  23. package/src/compose/ui.typecheck.ts +40 -4
  24. package/src/definition/definition.ts +22 -9
  25. package/src/event/event.fromSource.test.ts +172 -0
  26. package/src/event/event.test.ts +10 -0
  27. package/src/event/event.ts +102 -18
  28. package/src/feature/feature.mount.test.ts +123 -56
  29. package/src/feature/feature.test.ts +67 -63
  30. package/src/feature/feature.ts +1317 -197
  31. package/src/feature/feature.typecheck.ts +253 -61
  32. package/src/graph/closure.ts +403 -0
  33. package/src/index.ts +171 -245
  34. package/src/internal/bucketCache.ts +39 -0
  35. package/src/internal/capture.ts +9 -4
  36. package/src/internal/env.ts +1 -3
  37. package/src/internal/errors.ts +21 -10
  38. package/src/internal/queryDriver.ts +120 -15
  39. package/src/internal/queryDriverStore.ts +8 -5
  40. package/src/internal/queryDriverTypes.ts +3 -1
  41. package/src/internal/reuse.test.ts +10 -3
  42. package/src/internal/reuse.ts +5 -2
  43. package/src/internal/scheduler.ts +4 -1
  44. package/src/internal/sources.ts +25 -11
  45. package/src/internal/track.ts +3 -2
  46. package/src/internal.ts +222 -0
  47. package/src/namespace/namespace.ts +46 -25
  48. package/src/procedure/procedure.ts +38 -11
  49. package/src/reducer/reducer.ts +78 -34
  50. package/src/remote/remoteState.test.ts +515 -339
  51. package/src/remote/remoteState.ts +572 -107
  52. package/src/remote/remoteState.typecheck.ts +47 -23
  53. package/src/runtime/appRuntime.activation.test.ts +100 -0
  54. package/src/runtime/appRuntime.test.ts +157 -105
  55. package/src/runtime/appRuntime.ts +394 -33
  56. package/src/runtime/eventBudget.test.ts +1 -4
  57. package/src/runtime/eventBudget.ts +9 -8
  58. package/src/runtime/hardening.test.ts +4 -9
  59. package/src/runtime/instrumentation.test.ts +174 -192
  60. package/src/runtime/instrumentation.ts +6 -11
  61. package/src/runtime/loop.test.ts +36 -0
  62. package/src/runtime/loop.ts +69 -40
  63. package/src/scene/featureScene.test.ts +4 -2
  64. package/src/scene/scene.ts +234 -64
  65. package/src/scene/seedScene.test.ts +69 -86
  66. package/src/state/state.nominal.typecheck.ts +68 -0
  67. package/src/state/state.test.ts +17 -0
  68. package/src/state/state.ts +79 -26
  69. package/src/state/stateFamily.test.ts +14 -0
  70. package/src/state/stateFamily.ts +55 -22
  71. package/src/state/stateGroup.ts +53 -17
  72. package/src/state/token.ts +40 -10
  73. package/src/synced/syncedStore.ts +46 -23
  74. package/src/testkit/flight.testkit.ts +75 -0
  75. package/src/wire/tree.test.ts +8 -4
  76. package/src/wire/triggers.test.ts +6 -2
  77. package/src/wire/triggers.ts +14 -10
  78. package/src/calc/asyncCalcDefinitions.ts +0 -48
  79. package/src/channel/procedureRegistry.ts +0 -90
  80. package/src/feature/featureBinding.ts +0 -48
  81. package/src/internal/ctx.ts +0 -9
  82. package/src/remote/remoteStateDefinition.ts +0 -135
  83. package/src/remote/remoteStateLayers.ts +0 -118
  84. package/src/remote/remoteStateLiveTypes.ts +0 -59
  85. package/src/remote/remoteStateSend.ts +0 -64
@@ -3,7 +3,6 @@ import * as Boundary from '../boundary/boundary'
3
3
  import type { AsyncData } from '../calc/asyncData'
4
4
  import { type Channels, type Procedures } from '../channel/channel'
5
5
  import * as Event from '../event/event'
6
- import type { Store } from '../internal/store'
7
6
  import * as Reducer from '../reducer/reducer'
8
7
  import type { Bus } from '../runtime/bus'
9
8
  import { type Reducers } from '../runtime/loop'
@@ -12,8 +11,10 @@ import * as StateGroup from '../state/stateGroup'
12
11
  import { type AnySource, type Source } from '../state/token'
13
12
  import * as RemoteState from './remoteState'
14
13
 
15
- const AddIntentBase: Event.EventClass<'TAddIntent', { readonly id: string; readonly name: string }> =
16
- 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 }))
17
18
  class AddIntent extends AddIntentBase {}
18
19
  const RenameIntentBase: Event.EventClass<
19
20
  'TRenameIntent',
@@ -27,7 +28,10 @@ const PageBase: State.StateClass<'tcPage', number> = State.make('tcPage', S.Numb
27
28
  class Page extends PageBase {}
28
29
  const InputsBase: StateGroup.StateGroupClass<readonly [typeof Page]> = StateGroup.make(Page)
29
30
  class Inputs extends InputsBase {}
30
- 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
+ )
31
35
 
32
36
  const Row: S.Struct<{ id: typeof S.String; name: typeof S.String }> = S.Struct({
33
37
  id: S.String,
@@ -58,10 +62,11 @@ const ItemsBase: RemoteState.RemoteStateClass<
58
62
  class Items extends ItemsBase {}
59
63
 
60
64
  export const okLive: Layer.Layer<
61
- | Store<AsyncData<ReadonlyArray<Row>, string, true>>
62
- | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
65
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
66
+ | RemoteState.RemoteStateTruthStore<'TItems'>
67
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
63
68
  never,
64
- Store<number> | SendDep | Reducers | Procedures | Channels | Bus
69
+ State.StateStore<'tcPage', number> | SendDep | Reducers | Procedures | Channels | Bus
65
70
  > = RemoteState.live(Items, {
66
71
  query: ({ tcPage }) =>
67
72
  tcPage > 0 ? Effect.succeed<ReadonlyArray<Row>>([]) : Effect.fail('empty page'),
@@ -78,10 +83,11 @@ export const okLive: Layer.Layer<
78
83
  })
79
84
 
80
85
  export const badIntent: Layer.Layer<
81
- | Store<AsyncData<ReadonlyArray<Row>, string, true>>
82
- | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
86
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
87
+ | RemoteState.RemoteStateTruthStore<'TItems'>
88
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
83
89
  never,
84
- Store<number> | Reducers | Procedures | Channels | Bus
90
+ State.StateStore<'tcPage', number> | Reducers | Procedures | Channels | Bus
85
91
  > = RemoteState.live(Items, {
86
92
  query: () => Effect.succeed<ReadonlyArray<Row>>([]),
87
93
  send: () => Effect.void,
@@ -90,16 +96,20 @@ export const badIntent: Layer.Layer<
90
96
  })
91
97
 
92
98
  export const okLayer: Layer.Layer<
93
- Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
99
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
100
+ | RemoteState.RemoteStateTruthStore<'TItems'>
101
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
94
102
  never,
95
- Store<number> | SendDep | Reducers | Procedures | Channels | Bus
103
+ State.StateStore<'tcPage', number> | SendDep | Reducers | Procedures | Channels | Bus
96
104
  > = okLive
97
105
 
98
106
  // @ts-expect-error the send body's R2 must appear in the layer's requirements
99
107
  export const badLayer: Layer.Layer<
100
- Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
108
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
109
+ | RemoteState.RemoteStateTruthStore<'TItems'>
110
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
101
111
  never,
102
- Store<number> | Reducers | Procedures | Channels | Bus
112
+ State.StateStore<'tcPage', number> | Reducers | Procedures | Channels | Bus
103
113
  > = okLive
104
114
 
105
115
  const AlwaysItemsBase: RemoteState.RemoteStateClass<
@@ -118,10 +128,11 @@ const AlwaysItemsBase: RemoteState.RemoteStateClass<
118
128
  class AlwaysItems extends AlwaysItemsBase {}
119
129
 
120
130
  export const badDisabled: Layer.Layer<
121
- | Store<AsyncData<ReadonlyArray<Row>, never, false>>
122
- | Store<ReadonlyArray<RemoteState.PendingIntent<Event.EventType<typeof AddIntent>>>>,
131
+ | RemoteState.RemoteStateVisibleStore<'TAlwaysItems'>
132
+ | RemoteState.RemoteStateTruthStore<'TAlwaysItems'>
133
+ | RemoteState.RemoteStatePendingStore<'TAlwaysItems'>,
123
134
  never,
124
- Store<number> | Reducers | Procedures | Channels | Bus
135
+ State.StateStore<'tcPage', number> | Reducers | Procedures | Channels | Bus
125
136
  > = RemoteState.live(AlwaysItems, {
126
137
  query: () => Effect.succeed<ReadonlyArray<Row>>([]),
127
138
  send: () => Effect.void,
@@ -145,19 +156,32 @@ export const noIdle: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = idleArm
145
156
  // @ts-expect-error no `error` schema ⇒ no Error arm
146
157
  export const noError: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = errorArm
147
158
 
148
- 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
149
164
  export const classAsAnySource: AnySource = Items
150
165
 
151
- export const pendingAsSource: Source<'TItems/pending', ReadonlyArray<RemoteState.PendingIntent<Intent>>> =
152
- Items.pending
166
+ export const pendingAsSource: Source<
167
+ 'TItems/pending',
168
+ ReadonlyArray<RemoteState.PendingIntent<Intent>>,
169
+ RemoteState.RemoteStatePendingStore<'TItems'>
170
+ > = Items.pending
153
171
 
154
- export const truthAsSource: Source<'TItems/truth', AsyncData<ReadonlyArray<Row>, string, true>> =
155
- Items.truth
172
+ export const truthAsSource: Source<
173
+ 'TItems/truth',
174
+ AsyncData<ReadonlyArray<Row>, string, true>,
175
+ RemoteState.RemoteStateTruthStore<'TItems'>
176
+ > = Items.truth
156
177
 
157
178
  export const covered: Boundary.BoundaryClass<'TItemsBoundary', readonly [typeof Items]> =
158
179
  Boundary.make('TItemsBoundary', { over: [Items] })
159
180
 
160
- export const badReducerTarget: Reducer.StateReducerClass<State.AnyState, readonly [typeof AddIntent]> =
181
+ export const badReducerTarget: Reducer.StateReducerClass<
182
+ State.AnyState,
183
+ readonly [typeof AddIntent]
184
+ > =
161
185
  // @ts-expect-error the pending queue is not a reducible state
162
186
  Reducer.make('TBadReducer', {
163
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
+ )
@@ -1,18 +1,18 @@
1
- import { Context, Duration, Effect, Layer, 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";
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
16
 
17
17
  // The host-level mount lifecycle: `RuntimeHandle.mountFeature` forks the mount,
18
18
  // and its disposer must CANCEL a mount whose fiber has not run yet (StrictMode
@@ -20,38 +20,42 @@ import { makeAppRuntime } from "./appRuntime";
20
20
  // stale mount would still register reducers and dispatch `binding.boot` onto the
21
21
  // shared bus.
22
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", {
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
26
  states: [Count],
27
27
  events: [Tick],
28
28
  }) {}
29
29
 
30
- class CtrUi extends ui("appRuntime.CtrUi")<{ props: {} }>() {}
31
- class CtrComp extends Composition.make("appRuntime.CtrComp", {
32
- title: "Counter",
30
+ class CtrUi extends ui('appRuntime.CtrUi')<{ props: {} }>() {}
31
+ class CtrComp extends Composition.make('appRuntime.CtrComp', {
32
+ title: 'Counter',
33
33
  ui: CtrUi,
34
- }) {}
34
+ })<CtrComp>() {}
35
+ const CtrLive = Composition.live(CtrComp, function* () {
36
+ return mount({ props: {}, slots: {}, events: {} })
37
+ })
35
38
 
36
- class CounterFeature extends Feature.make("appRuntime.counter", {
39
+ class CounterFeature extends Feature.make('appRuntime.counter', {
37
40
  composition: CtrComp,
38
41
  module: featureModule(
39
42
  [],
40
- Reducer.live(CountReducer, (n) => n + 1).pipe(
41
- Layer.provideMerge(State.live(Count, 0)),
43
+ Layer.merge(
44
+ CtrLive,
45
+ Reducer.live(CountReducer, (n) => n + 1).pipe(Layer.provideMerge(State.live(Count, 0))),
42
46
  ),
43
47
  ),
44
48
  boot: [Event.construct(Tick, {})],
45
49
  }) {}
46
50
 
47
- class RootUi extends ui("appRuntime.RootUi")<{ props: {} }>() {}
48
- class RootComp extends Composition.make("appRuntime.RootComp", {
49
- title: "Root",
51
+ class RootUi extends ui('appRuntime.RootUi')<{ props: {} }>() {}
52
+ class RootComp extends Composition.make('appRuntime.RootComp', {
53
+ title: 'Root',
50
54
  ui: RootUi,
51
- }) {}
55
+ })<RootComp>() {}
52
56
  const RootLive = Composition.live(RootComp, function* () {
53
- return mount({ props: {}, slots: {}, events: {} });
54
- });
57
+ return mount({ props: {}, slots: {}, events: {} })
58
+ })
55
59
 
56
60
  const rootScene = () =>
57
61
  scene(RootComp, {
@@ -66,132 +70,180 @@ const rootScene = () =>
66
70
  Layer.provideMerge(Engine),
67
71
  ),
68
72
  ],
69
- });
73
+ })
70
74
 
71
- const settle = Effect.sleep(Duration.millis(50));
75
+ const settle = Effect.sleep(Duration.millis(50))
72
76
  const appRuntime = Effect.acquireRelease(
73
77
  Effect.sync(() => makeAppRuntime(rootScene())),
74
78
  (app) => Effect.sync(() => app.dispose()),
75
- );
79
+ )
76
80
 
77
81
  interface ParentValueService {
78
- readonly value: string;
82
+ readonly value: string
79
83
  }
80
- const ParentValueBase: Context.TagClass<
81
- ParentValue,
82
- "appRuntime.ParentValue",
83
- ParentValueService
84
- > = Context.Tag("appRuntime.ParentValue")<ParentValue, ParentValueService>();
84
+ const ParentValueBase: Context.TagClass<ParentValue, 'appRuntime.ParentValue', ParentValueService> =
85
+ Context.Tag('appRuntime.ParentValue')<ParentValue, ParentValueService>()
85
86
  class ParentValue extends ParentValueBase {}
86
87
 
87
88
  interface ChildValueService {
88
- readonly value: string;
89
+ readonly value: string
89
90
  }
90
- const ChildValueBase: Context.TagClass<
91
- ChildValue,
92
- "appRuntime.ChildValue",
93
- ChildValueService
94
- > = Context.Tag("appRuntime.ChildValue")<ChildValue, ChildValueService>();
91
+ const ChildValueBase: Context.TagClass<ChildValue, 'appRuntime.ChildValue', ChildValueService> =
92
+ Context.Tag('appRuntime.ChildValue')<ChildValue, ChildValueService>()
95
93
  class ChildValue extends ChildValueBase {}
96
94
 
97
- class ParentFeature extends Feature.make("appRuntime.parent", {
95
+ class ParentFeature extends Feature.make('appRuntime.parent', {
98
96
  composition: CtrComp,
99
97
  module: featureModule(
100
98
  [],
101
- Layer.succeed(ParentValue, { value: "from-parent" }),
99
+ Layer.merge(CtrLive, Layer.succeed(ParentValue, { value: 'from-parent' })),
102
100
  ),
103
101
  }) {}
104
102
 
105
- class ChildFeature extends Feature.make("appRuntime.child", {
103
+ class ChildFeature extends Feature.make('appRuntime.child', {
106
104
  composition: CtrComp,
107
105
  module: featureModule(
108
106
  [ParentValue],
109
- Layer.effect(
110
- ChildValue,
111
- Effect.map(ParentValue, ({ value }) => ({ value })),
107
+ Layer.merge(
108
+ CtrLive,
109
+ Layer.effect(
110
+ ChildValue,
111
+ Effect.map(ParentValue, ({ value }) => ({ value })),
112
+ ),
112
113
  ),
113
114
  ),
114
115
  }) {}
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
+
116
121
  it.scopedLive(
117
- "disposing a mount before its fiber runs cancels it — no registration, no boot, no handler calls",
122
+ 'disposing a mount before its fiber runs cancels it — no registration, no boot, no handler calls',
118
123
  () =>
119
124
  Effect.gen(function* () {
120
- const app = yield* appRuntime;
121
- const baseline = app.read(Reducers).entries.length;
122
- const calls = { live: 0, failed: 0 };
123
- const dispose = app.mountFeature(CounterFeature.binding, {
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, {
124
129
  onLive: () => {
125
- calls.live += 1;
130
+ calls.live += 1
126
131
  },
127
132
  onFailed: () => {
128
- calls.failed += 1;
133
+ calls.failed += 1
129
134
  },
130
- });
135
+ })
131
136
  // Same-tick dispose (the StrictMode cleanup shape): the fork has not run yet.
132
- dispose();
133
- yield* settle;
134
- expect(calls).toEqual({ live: 0, failed: 0 });
135
- expect(app.read(Reducers).entries.length).toBe(baseline);
136
- expect(app.read(Reducers).byTag.get("appRuntime.Tick")).toBeUndefined();
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()
137
142
  }),
138
- );
143
+ )
139
144
 
140
145
  it.scopedLive(
141
- "an undisposed mount still goes live, and disposing it after live reclaims the registration",
146
+ 'an undisposed mount still goes live, and disposing it after live reclaims the registration',
142
147
  () =>
143
148
  Effect.gen(function* () {
144
- const app = yield* appRuntime;
145
- const baseline = app.read(Reducers).entries.length;
146
- const calls = { live: 0, failed: 0 };
147
- const dispose = app.mountFeature(CounterFeature.binding, {
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, {
148
153
  onLive: () => {
149
- calls.live += 1;
154
+ calls.live += 1
150
155
  },
151
156
  onFailed: () => {
152
- calls.failed += 1;
157
+ calls.failed += 1
153
158
  },
154
- });
159
+ })
155
160
  yield* Effect.sleep(Duration.millis(1)).pipe(
156
161
  Effect.repeat({ until: () => calls.live === 1 }),
157
162
  Effect.timeout(Duration.seconds(2)),
158
- );
159
- expect(calls).toEqual({ live: 1, failed: 0 });
160
- expect(app.read(Reducers).entries.length).toBe(baseline + 1);
161
- dispose();
162
- yield* settle;
163
- expect(app.read(Reducers).entries.length).toBe(baseline);
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'])
164
227
  }),
165
- );
228
+ )
166
229
 
167
230
  it.scopedLive(
168
- "a nested Feature mounts against its immediate parent context",
231
+ 'an existential Feature binding fails before build when a requirement is absent',
169
232
  () =>
170
233
  Effect.gen(function* () {
171
- const app = yield* appRuntime;
172
- const observed: Array<string> = [];
173
- const disposers: Array<() => void> = [];
174
- yield* Effect.addFinalizer(() =>
175
- Effect.sync(() => disposers.reverse().forEach((dispose) => dispose())),
176
- );
177
- disposers.push(
178
- app.mountFeature(ParentFeature.binding, {
179
- onFailed: () => observed.push("parent-failed"),
180
- onLive: (parent) => {
181
- disposers.push(
182
- parent.mountFeature(ChildFeature.binding, {
183
- onFailed: () => observed.push("child-failed"),
184
- onLive: (child) =>
185
- observed.push(child.read(ChildValue).value),
186
- }),
187
- );
188
- },
189
- }),
190
- );
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))
191
243
  yield* Effect.sleep(Duration.millis(1)).pipe(
192
244
  Effect.repeat({ until: () => observed.length > 0 }),
193
245
  Effect.timeout(Duration.seconds(2)),
194
- );
195
- expect(observed).toEqual(["from-parent"]);
246
+ )
247
+ expect(observed).toEqual(['failed'])
196
248
  }),
197
- );
249
+ )