@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.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- 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<
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
|
72
|
-
|
|
|
65
|
+
| RemoteState.RemoteStateVisibleStore<'TItems'>
|
|
66
|
+
| RemoteState.RemoteStateTruthStore<'TItems'>
|
|
67
|
+
| RemoteState.RemoteStatePendingStore<'TItems'>,
|
|
73
68
|
never,
|
|
74
|
-
|
|
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
|
-
|
|
|
93
|
-
|
|
|
86
|
+
| RemoteState.RemoteStateVisibleStore<'TItems'>
|
|
87
|
+
| RemoteState.RemoteStateTruthStore<'TItems'>
|
|
88
|
+
| RemoteState.RemoteStatePendingStore<'TItems'>,
|
|
94
89
|
never,
|
|
95
|
-
|
|
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
|
-
|
|
99
|
+
| RemoteState.RemoteStateVisibleStore<'TItems'>
|
|
100
|
+
| RemoteState.RemoteStateTruthStore<'TItems'>
|
|
101
|
+
| RemoteState.RemoteStatePendingStore<'TItems'>,
|
|
109
102
|
never,
|
|
110
|
-
|
|
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
|
-
|
|
108
|
+
| RemoteState.RemoteStateVisibleStore<'TItems'>
|
|
109
|
+
| RemoteState.RemoteStateTruthStore<'TItems'>
|
|
110
|
+
| RemoteState.RemoteStatePendingStore<'TItems'>,
|
|
118
111
|
never,
|
|
119
|
-
|
|
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
|
-
|
|
|
142
|
-
|
|
|
131
|
+
| RemoteState.RemoteStateVisibleStore<'TAlwaysItems'>
|
|
132
|
+
| RemoteState.RemoteStateTruthStore<'TAlwaysItems'>
|
|
133
|
+
| RemoteState.RemoteStatePendingStore<'TAlwaysItems'>,
|
|
143
134
|
never,
|
|
144
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
166
|
+
export const pendingAsSource: Source<
|
|
167
|
+
'TItems/pending',
|
|
168
|
+
ReadonlyArray<RemoteState.PendingIntent<Intent>>,
|
|
169
|
+
RemoteState.RemoteStatePendingStore<'TItems'>
|
|
170
|
+
> = Items.pending
|
|
181
171
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
)
|