@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.
- package/README.md +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +62 -16
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +516 -18
- package/src/calc/asyncCalc.test.ts +207 -175
- package/src/calc/asyncCalc.ts +168 -39
- package/src/calc/calc.test.ts +3 -5
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +80 -22
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +128 -11
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +346 -62
- package/src/compose/props.ts +13 -6
- package/src/compose/provide.ts +187 -46
- package/src/compose/slot.ts +156 -19
- package/src/compose/structure.test.ts +6 -3
- package/src/compose/structure.ts +106 -13
- package/src/compose/ui.test.ts +19 -3
- package/src/compose/ui.ts +147 -54
- package/src/compose/ui.typecheck.ts +40 -4
- package/src/definition/definition.ts +22 -9
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -0
- package/src/event/event.ts +102 -18
- package/src/feature/feature.mount.test.ts +123 -56
- package/src/feature/feature.test.ts +67 -63
- package/src/feature/feature.ts +1317 -197
- package/src/feature/feature.typecheck.ts +253 -61
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +171 -245
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -4
- package/src/internal/env.ts +1 -3
- package/src/internal/errors.ts +21 -10
- package/src/internal/queryDriver.ts +120 -15
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +3 -1
- package/src/internal/reuse.test.ts +10 -3
- package/src/internal/reuse.ts +5 -2
- package/src/internal/scheduler.ts +4 -1
- package/src/internal/sources.ts +25 -11
- package/src/internal/track.ts +3 -2
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.ts +46 -25
- package/src/procedure/procedure.ts +38 -11
- package/src/reducer/reducer.ts +78 -34
- package/src/remote/remoteState.test.ts +515 -339
- package/src/remote/remoteState.ts +572 -107
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +157 -105
- package/src/runtime/appRuntime.ts +394 -33
- package/src/runtime/eventBudget.test.ts +1 -4
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/hardening.test.ts +4 -9
- package/src/runtime/instrumentation.test.ts +174 -192
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +36 -0
- package/src/runtime/loop.ts +69 -40
- package/src/scene/featureScene.test.ts +4 -2
- package/src/scene/scene.ts +234 -64
- package/src/scene/seedScene.test.ts +69 -86
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +79 -26
- package/src/state/stateFamily.test.ts +14 -0
- package/src/state/stateFamily.ts +55 -22
- package/src/state/stateGroup.ts +53 -17
- package/src/state/token.ts +40 -10
- package/src/synced/syncedStore.ts +46 -23
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/wire/tree.test.ts +8 -4
- package/src/wire/triggers.test.ts +6 -2
- package/src/wire/triggers.ts +14 -10
- package/src/calc/asyncCalcDefinitions.ts +0 -48
- package/src/channel/procedureRegistry.ts +0 -90
- package/src/feature/featureBinding.ts +0 -48
- package/src/internal/ctx.ts +0 -9
- package/src/remote/remoteStateDefinition.ts +0 -135
- package/src/remote/remoteStateLayers.ts +0 -118
- package/src/remote/remoteStateLiveTypes.ts +0 -59
- 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<
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
|
62
|
-
|
|
|
65
|
+
| RemoteState.RemoteStateVisibleStore<'TItems'>
|
|
66
|
+
| RemoteState.RemoteStateTruthStore<'TItems'>
|
|
67
|
+
| RemoteState.RemoteStatePendingStore<'TItems'>,
|
|
63
68
|
never,
|
|
64
|
-
|
|
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
|
-
|
|
|
82
|
-
|
|
|
86
|
+
| RemoteState.RemoteStateVisibleStore<'TItems'>
|
|
87
|
+
| RemoteState.RemoteStateTruthStore<'TItems'>
|
|
88
|
+
| RemoteState.RemoteStatePendingStore<'TItems'>,
|
|
83
89
|
never,
|
|
84
|
-
|
|
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
|
-
|
|
99
|
+
| RemoteState.RemoteStateVisibleStore<'TItems'>
|
|
100
|
+
| RemoteState.RemoteStateTruthStore<'TItems'>
|
|
101
|
+
| RemoteState.RemoteStatePendingStore<'TItems'>,
|
|
94
102
|
never,
|
|
95
|
-
|
|
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
|
-
|
|
108
|
+
| RemoteState.RemoteStateVisibleStore<'TItems'>
|
|
109
|
+
| RemoteState.RemoteStateTruthStore<'TItems'>
|
|
110
|
+
| RemoteState.RemoteStatePendingStore<'TItems'>,
|
|
101
111
|
never,
|
|
102
|
-
|
|
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
|
-
|
|
|
122
|
-
|
|
|
131
|
+
| RemoteState.RemoteStateVisibleStore<'TAlwaysItems'>
|
|
132
|
+
| RemoteState.RemoteStateTruthStore<'TAlwaysItems'>
|
|
133
|
+
| RemoteState.RemoteStatePendingStore<'TAlwaysItems'>,
|
|
123
134
|
never,
|
|
124
|
-
|
|
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<
|
|
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<
|
|
152
|
-
|
|
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<
|
|
155
|
-
|
|
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<
|
|
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
|
|
2
|
-
import { expect, it } from
|
|
3
|
-
import * as Composition from
|
|
4
|
-
import { provide } from
|
|
5
|
-
import { mount } from
|
|
6
|
-
import * as Ui from
|
|
7
|
-
import { ui } from
|
|
8
|
-
import * as Event from
|
|
9
|
-
import * as Feature from
|
|
10
|
-
import { featureModule } from
|
|
11
|
-
import * as Reducer from
|
|
12
|
-
import { scene } from
|
|
13
|
-
import * as State from
|
|
14
|
-
import { Engine, Reducers } from
|
|
15
|
-
import { makeAppRuntime } from
|
|
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(
|
|
24
|
-
class Count extends State.make(
|
|
25
|
-
class CountReducer extends Reducer.make(
|
|
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(
|
|
31
|
-
class CtrComp extends Composition.make(
|
|
32
|
-
title:
|
|
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(
|
|
39
|
+
class CounterFeature extends Feature.make('appRuntime.counter', {
|
|
37
40
|
composition: CtrComp,
|
|
38
41
|
module: featureModule(
|
|
39
42
|
[],
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
48
|
-
class RootComp extends Composition.make(
|
|
49
|
-
title:
|
|
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(
|
|
95
|
+
class ParentFeature extends Feature.make('appRuntime.parent', {
|
|
98
96
|
composition: CtrComp,
|
|
99
97
|
module: featureModule(
|
|
100
98
|
[],
|
|
101
|
-
Layer.succeed(ParentValue, { value:
|
|
99
|
+
Layer.merge(CtrLive, Layer.succeed(ParentValue, { value: 'from-parent' })),
|
|
102
100
|
),
|
|
103
101
|
}) {}
|
|
104
102
|
|
|
105
|
-
class ChildFeature extends Feature.make(
|
|
103
|
+
class ChildFeature extends Feature.make('appRuntime.child', {
|
|
106
104
|
composition: CtrComp,
|
|
107
105
|
module: featureModule(
|
|
108
106
|
[ParentValue],
|
|
109
|
-
Layer.
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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([
|
|
246
|
+
)
|
|
247
|
+
expect(observed).toEqual(['failed'])
|
|
196
248
|
}),
|
|
197
|
-
)
|
|
249
|
+
)
|