@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
package/src/event/event.ts
CHANGED
|
@@ -1,33 +1,57 @@
|
|
|
1
|
-
import { Effect, PubSub, Runtime } from 'effect'
|
|
1
|
+
import { Effect, Layer, MutableRef, Option, PubSub, Runtime, Schema } from 'effect'
|
|
2
2
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
|
+
import { resolveScheduler } from '../internal/scheduler'
|
|
3
4
|
import { Bus, publish, type Tagged } from '../runtime/bus'
|
|
5
|
+
import type { Source } from '../state/token'
|
|
4
6
|
import type { Trigger } from '../ui/trigger'
|
|
5
|
-
import type { AnySchema, AnyValue } from '../internal/variance'
|
|
6
7
|
|
|
7
8
|
export type EventOf<N extends string, P> = Tagged & { readonly _tag: N } & P
|
|
8
9
|
|
|
9
|
-
export interface
|
|
10
|
+
export interface EventSchemaReflection {
|
|
11
|
+
readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EventManifest<N extends string> extends Manifest {
|
|
10
15
|
readonly kind: 'Event'
|
|
11
16
|
readonly name: N
|
|
12
|
-
readonly schema:
|
|
17
|
+
readonly schema: EventSchemaReflection
|
|
13
18
|
}
|
|
14
19
|
|
|
15
|
-
export interface
|
|
20
|
+
export interface EventReflection<out N extends string = string, out P = unknown> {
|
|
16
21
|
new (): {}
|
|
17
|
-
readonly manifest: EventManifest<N
|
|
22
|
+
readonly manifest: EventManifest<N>
|
|
18
23
|
readonly tag: N
|
|
24
|
+
readonly Payload: P
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type EventCapture<Result> = <N extends string, P>(event: EventClass<N, P>) => Result
|
|
28
|
+
|
|
29
|
+
export interface AnyEvent extends EventReflection {
|
|
30
|
+
readonly buildUnknown: (payload: unknown) => Tagged
|
|
31
|
+
readonly capture: <Result>(visit: EventCapture<Result>) => Result
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EventClass<out N extends string, in out P> extends EventReflection<N, P> {
|
|
19
35
|
readonly build: (payload: P) => EventOf<N, P>
|
|
20
36
|
readonly trigger: Effect.Effect<Trigger<P>, never, Bus>
|
|
37
|
+
readonly buildUnknown: (payload: unknown) => Tagged
|
|
38
|
+
readonly capture: <Result>(visit: EventCapture<Result>) => Result
|
|
21
39
|
}
|
|
22
40
|
|
|
23
|
-
export type
|
|
24
|
-
export type
|
|
41
|
+
export type EventName<E> = E extends EventReflection<infer N, infer _P> ? N : never
|
|
42
|
+
export type EventPayload<E> = E extends EventReflection<string, infer P> ? P : never
|
|
43
|
+
export type EventType<E> = E extends EventReflection<infer N, infer P> ? EventOf<N, P> : never
|
|
25
44
|
|
|
26
|
-
export const make = <const N extends string, P>(
|
|
45
|
+
export const make = <const N extends string, P, Encoded>(
|
|
27
46
|
name: N,
|
|
28
|
-
schema:
|
|
47
|
+
schema: Schema.Schema<P, Encoded, never>,
|
|
29
48
|
): EventClass<N, P> => {
|
|
30
|
-
const build = (payload: P): EventOf<N, P> => ({ _tag: name
|
|
49
|
+
const build = (payload: P): EventOf<N, P> => ({ ...payload, _tag: name })
|
|
50
|
+
// Payloads at existential boundaries are already-decoded domain values, so
|
|
51
|
+
// check the Type side — decodeUnknownSync would reject transforming schemas
|
|
52
|
+
// (e.g. a real Option, whose _tag lives on the prototype).
|
|
53
|
+
const validatePayload = Schema.validateSync(schema)
|
|
54
|
+
const buildUnknown = (payload: unknown): Tagged => build(validatePayload(payload))
|
|
31
55
|
const trigger = Effect.gen(function* () {
|
|
32
56
|
const bus = yield* Bus
|
|
33
57
|
const runtime = yield* Effect.runtime<never>()
|
|
@@ -35,22 +59,82 @@ export const make = <const N extends string, P>(
|
|
|
35
59
|
Runtime.runSync(runtime)(PubSub.publish(bus, { priority: 'High', event: build(payload) }))
|
|
36
60
|
}
|
|
37
61
|
})
|
|
38
|
-
|
|
62
|
+
const event: EventClass<N, P> = definitionClass<EventClass<N, P>>({
|
|
39
63
|
manifest: { kind: 'Event' as const, name, schema },
|
|
40
64
|
tag: name,
|
|
41
65
|
build,
|
|
66
|
+
buildUnknown,
|
|
42
67
|
trigger,
|
|
68
|
+
capture: <Result>(visit: EventCapture<Result>): Result => visit(event),
|
|
43
69
|
})
|
|
70
|
+
return event
|
|
44
71
|
}
|
|
45
72
|
|
|
46
|
-
export
|
|
47
|
-
|
|
73
|
+
export function construct<N extends string, P>(event: EventClass<N, P>, payload: P): EventOf<N, P>
|
|
74
|
+
export function construct<E extends AnyEvent>(event: E, payload: EventPayload<E>): EventType<E>
|
|
75
|
+
export function construct(event: AnyEvent, payload: unknown): Tagged {
|
|
76
|
+
return event.buildUnknown(payload)
|
|
77
|
+
}
|
|
48
78
|
|
|
49
|
-
export const dispatch = <
|
|
50
|
-
event:
|
|
51
|
-
payload:
|
|
52
|
-
): Effect.Effect<void, never, Bus> => publish('Normal', event.
|
|
79
|
+
export const dispatch = <E extends AnyEvent>(
|
|
80
|
+
event: E,
|
|
81
|
+
payload: EventPayload<E>,
|
|
82
|
+
): Effect.Effect<void, never, Bus> => publish('Normal', event.buildUnknown(payload))
|
|
53
83
|
|
|
54
84
|
export const trigger = <N extends string, P>(
|
|
55
85
|
event: EventClass<N, P>,
|
|
56
86
|
): Effect.Effect<Trigger<P>, never, Bus> => event.trigger
|
|
87
|
+
|
|
88
|
+
export interface SourceChange<A> {
|
|
89
|
+
readonly previous: Option.Option<A>
|
|
90
|
+
readonly current: A
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SourceEventConfigExternalApi<A, P> {
|
|
94
|
+
readonly emitInitial?: boolean
|
|
95
|
+
readonly payload: (change: SourceChange<A>) => Option.Option<P>
|
|
96
|
+
}
|
|
97
|
+
export type SourceEventConfig<A, P> = SourceEventConfigExternalApi<A, P>
|
|
98
|
+
|
|
99
|
+
export const liveFromSource = <N extends string, P, SourceName extends string, A, Identifier>(
|
|
100
|
+
event: EventClass<N, P>,
|
|
101
|
+
source: Source<SourceName, A, Identifier>,
|
|
102
|
+
config: SourceEventConfigExternalApi<A, P>,
|
|
103
|
+
): Layer.Layer<never, never, Identifier | Bus> =>
|
|
104
|
+
Layer.scopedDiscard(
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const store = yield* source.store
|
|
107
|
+
const scheduler = yield* resolveScheduler
|
|
108
|
+
const runtime = yield* Effect.runtime<Bus>()
|
|
109
|
+
const initial = store.getSnapshot()
|
|
110
|
+
// The scheduler coalesces bursts, so track the last value this adapter observed.
|
|
111
|
+
const seen = MutableRef.make(initial)
|
|
112
|
+
// A queued notification may outlive unsubscribe within the same tick.
|
|
113
|
+
const active = MutableRef.make(true)
|
|
114
|
+
const emit = (change: SourceChange<A>): void => {
|
|
115
|
+
if (!MutableRef.get(active)) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const payload = config.payload(change)
|
|
119
|
+
if (Option.isSome(payload)) {
|
|
120
|
+
Runtime.runSync(runtime)(publish('Normal', event.build(payload.value)))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const unsubscribe = store.subscribe(() => {
|
|
124
|
+
const current = store.getSnapshot()
|
|
125
|
+
const previous = MutableRef.get(seen)
|
|
126
|
+
MutableRef.set(seen, current)
|
|
127
|
+
emit({ previous: Option.some(previous), current })
|
|
128
|
+
})
|
|
129
|
+
yield* Effect.addFinalizer(() =>
|
|
130
|
+
Effect.sync(() => {
|
|
131
|
+
MutableRef.set(active, false)
|
|
132
|
+
unsubscribe()
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
if (config.emitInitial === true) {
|
|
136
|
+
// Defer until reducers and procedures from the same layer build have registered.
|
|
137
|
+
scheduler.schedule([() => emit({ previous: Option.none(), current: initial })])
|
|
138
|
+
}
|
|
139
|
+
}),
|
|
140
|
+
)
|
|
@@ -1,46 +1,105 @@
|
|
|
1
|
-
import { type Cause, Duration, Effect, Exit, Layer, Scope } from
|
|
2
|
-
import { Schema as S } from
|
|
3
|
-
import { expect, it } from
|
|
4
|
-
import * as Composition from
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import * as
|
|
8
|
-
import * as
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
import
|
|
1
|
+
import { type Cause, Context, Duration, Effect, Exit, Layer, Scope } from 'effect'
|
|
2
|
+
import { Schema as S } from 'effect'
|
|
3
|
+
import { expect, it } from '@effect/vitest'
|
|
4
|
+
import * as Composition from '../compose/composition'
|
|
5
|
+
import { mount } from '../compose/structure'
|
|
6
|
+
import { ui } from '../compose/ui'
|
|
7
|
+
import * as Event from '../event/event'
|
|
8
|
+
import * as Reducer from '../reducer/reducer'
|
|
9
|
+
import * as State from '../state/state'
|
|
10
|
+
import { publish } from '../runtime/bus'
|
|
11
|
+
import { Engine, Reducers } from '../runtime/loop'
|
|
12
|
+
import * as Feature from './feature'
|
|
13
|
+
import { type EngineServices, featureModule, mountFeature } from './feature'
|
|
13
14
|
|
|
14
15
|
// A self-contained feature: a Count state + a reducer that folds Tick. The base
|
|
15
16
|
// seeds Count; the logic provides the reducer over it and leaves the engine
|
|
16
17
|
// (Reducers registry) OPEN, so `mountFeature` builds it against the live engine.
|
|
17
|
-
class Tick extends Event.make(
|
|
18
|
-
class Count extends State.make(
|
|
19
|
-
class CountReducer extends Reducer.make(
|
|
18
|
+
class Tick extends Event.make('Tick', S.Struct({})) {}
|
|
19
|
+
class Count extends State.make('count', S.Number) {}
|
|
20
|
+
class CountReducer extends Reducer.make('CountReducer', {
|
|
20
21
|
states: [Count],
|
|
21
22
|
events: [Tick],
|
|
22
23
|
}) {}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
)
|
|
25
|
+
class MountedCounterProbe extends Context.Tag('test/MountedCounterProbe')<
|
|
26
|
+
MountedCounterProbe,
|
|
27
|
+
{ readonly value: number }
|
|
28
|
+
>() {}
|
|
29
|
+
|
|
30
|
+
class LazyMountedProbe extends Context.Tag('test/LazyMountedProbe')<
|
|
31
|
+
LazyMountedProbe,
|
|
32
|
+
{ readonly value: string }
|
|
33
|
+
>() {}
|
|
34
|
+
|
|
35
|
+
class UnmountedProbe extends Context.Tag('test/UnmountedProbe')<
|
|
36
|
+
UnmountedProbe,
|
|
37
|
+
{ readonly value: boolean }
|
|
38
|
+
>() {}
|
|
30
39
|
|
|
31
40
|
// A trivial composition to hang the feature off (definitions only).
|
|
32
|
-
class CtrUi extends ui(
|
|
33
|
-
class CtrComp extends Composition.make(
|
|
34
|
-
title:
|
|
41
|
+
class CtrUi extends ui('CtrUi')<{ props: {}; events: {} }>() {}
|
|
42
|
+
class CtrComp extends Composition.make('CtrComp', {
|
|
43
|
+
title: 'Counter',
|
|
35
44
|
ui: CtrUi,
|
|
36
|
-
}) {}
|
|
45
|
+
})<CtrComp>() {}
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
const CtrLive = Composition.live(CtrComp, function* () {
|
|
48
|
+
return mount({ props: {}, slots: {} })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const mod = featureModule(
|
|
52
|
+
[],
|
|
53
|
+
Layer.mergeAll(
|
|
54
|
+
CtrLive,
|
|
55
|
+
Reducer.live(CountReducer, (n) => n + 1).pipe(Layer.provideMerge(State.live(Count, 0))),
|
|
56
|
+
Layer.succeed(MountedCounterProbe, { value: 42 }),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
class CounterFeature extends Feature.make('counter', {
|
|
39
61
|
composition: CtrComp,
|
|
40
62
|
module: mod,
|
|
41
63
|
boot: [Event.construct(Tick, {})],
|
|
42
64
|
}) {}
|
|
43
65
|
|
|
66
|
+
const lazyMod = featureModule(
|
|
67
|
+
[],
|
|
68
|
+
Layer.merge(CtrLive, Layer.succeed(LazyMountedProbe, { value: 'lazy' })),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
class LazyCounterFeature extends Feature.make('lazy-counter', {
|
|
72
|
+
composition: CtrComp,
|
|
73
|
+
loadingStrategy: 'lazy',
|
|
74
|
+
load: Effect.succeed(lazyMod),
|
|
75
|
+
placeholder: { loading: CtrComp, failed: CtrComp },
|
|
76
|
+
}) {}
|
|
77
|
+
|
|
78
|
+
const CounterNative = Feature.tree<typeof CounterFeature>(CounterFeature.token)
|
|
79
|
+
const LazyCounterNative = Feature.tree<typeof LazyCounterFeature>(LazyCounterFeature.token)
|
|
80
|
+
|
|
81
|
+
const mountCounterFeature = (engineContext: Context.Context<EngineServices>) =>
|
|
82
|
+
mountFeature(CounterNative.binding, engineContext)
|
|
83
|
+
|
|
84
|
+
const mountLazyCounterFeature = (engineContext: Context.Context<EngineServices>) =>
|
|
85
|
+
mountFeature(LazyCounterNative.binding, engineContext)
|
|
86
|
+
|
|
87
|
+
type ContextServices<C> = C extends Context.Context<infer Services> ? Services : never
|
|
88
|
+
type EagerMountedServices = ContextServices<
|
|
89
|
+
Effect.Effect.Success<ReturnType<typeof mountCounterFeature>>
|
|
90
|
+
>
|
|
91
|
+
type LazyMountedServices = ContextServices<
|
|
92
|
+
Effect.Effect.Success<ReturnType<typeof mountLazyCounterFeature>>
|
|
93
|
+
>
|
|
94
|
+
type Includes<Services, Candidate> = Candidate extends Services ? true : false
|
|
95
|
+
|
|
96
|
+
const mountedServiceTypeProof: readonly [
|
|
97
|
+
Includes<EagerMountedServices, MountedCounterProbe>,
|
|
98
|
+
Includes<EagerMountedServices, UnmountedProbe>,
|
|
99
|
+
Includes<LazyMountedServices, LazyMountedProbe>,
|
|
100
|
+
Includes<LazyMountedServices, UnmountedProbe>,
|
|
101
|
+
] = [true, false, true, false]
|
|
102
|
+
|
|
44
103
|
// Poll a read until it satisfies a predicate (the loop drains on a forked fiber).
|
|
45
104
|
const waitUntil = <A, R>(
|
|
46
105
|
read: Effect.Effect<A, never, R>,
|
|
@@ -49,46 +108,54 @@ const waitUntil = <A, R>(
|
|
|
49
108
|
Effect.flatMap(read, (a) =>
|
|
50
109
|
pred(a)
|
|
51
110
|
? Effect.succeed(a)
|
|
52
|
-
: Effect.flatMap(Effect.sleep(Duration.millis(1)), () =>
|
|
53
|
-
|
|
54
|
-
),
|
|
55
|
-
).pipe(Effect.timeout(Duration.seconds(2)));
|
|
111
|
+
: Effect.flatMap(Effect.sleep(Duration.millis(1)), () => waitUntil(read, pred)),
|
|
112
|
+
).pipe(Effect.timeout(Duration.seconds(2)))
|
|
56
113
|
|
|
57
114
|
it.scopedLive(
|
|
58
|
-
|
|
115
|
+
'a mounted feature registers on the shared loop, boots, and folds dispatched events',
|
|
59
116
|
() =>
|
|
60
117
|
Effect.gen(function* () {
|
|
61
|
-
const engineContext = yield* Effect.context<EngineServices>()
|
|
62
|
-
const reducers = yield* Reducers
|
|
63
|
-
const baseline = reducers.entries.length
|
|
64
|
-
|
|
65
|
-
const scope = yield* Scope.make()
|
|
66
|
-
const ctx = yield*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
).
|
|
118
|
+
const engineContext = yield* Effect.context<EngineServices>()
|
|
119
|
+
const reducers = yield* Reducers
|
|
120
|
+
const baseline = reducers.entries.length
|
|
121
|
+
|
|
122
|
+
const scope = yield* Scope.make()
|
|
123
|
+
const ctx = yield* mountCounterFeature(engineContext).pipe(
|
|
124
|
+
Effect.provideService(Scope.Scope, scope),
|
|
125
|
+
)
|
|
126
|
+
expect(mountedServiceTypeProof).toEqual([true, false, true, false])
|
|
127
|
+
expect(Context.get(ctx, MountedCounterProbe).value).toBe(42)
|
|
128
|
+
expect(Context.getOption(ctx, UnmountedProbe)._tag).toBe('None')
|
|
70
129
|
// Registered into the SHARED registry.
|
|
71
|
-
expect(reducers.entries.length).toBe(baseline + 1)
|
|
130
|
+
expect(reducers.entries.length).toBe(baseline + 1)
|
|
72
131
|
|
|
73
132
|
// boot dispatched Tick → Count folds to 1 on the shared loop.
|
|
74
|
-
const booted = yield* waitUntil(
|
|
75
|
-
|
|
76
|
-
(n) => n === 1,
|
|
77
|
-
);
|
|
78
|
-
expect(booted).toBe(1);
|
|
133
|
+
const booted = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 1)
|
|
134
|
+
expect(booted).toBe(1)
|
|
79
135
|
|
|
80
136
|
// A further dispatch on the shared bus folds again → 2.
|
|
81
|
-
yield* publish(
|
|
82
|
-
const folded = yield* waitUntil(
|
|
83
|
-
|
|
84
|
-
(n) => n === 2,
|
|
85
|
-
);
|
|
86
|
-
expect(folded).toBe(2);
|
|
137
|
+
yield* publish('High', Event.construct(Tick, {}))
|
|
138
|
+
const folded = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 2)
|
|
139
|
+
expect(folded).toBe(2)
|
|
87
140
|
|
|
88
141
|
// Unmount: closing the feature scope unregisters its reducer — the entry is
|
|
89
142
|
// reclaimed, not left folding onto an orphaned store.
|
|
90
|
-
yield* Scope.close(scope, Exit.succeed(undefined))
|
|
91
|
-
expect(reducers.entries.length).toBe(baseline)
|
|
92
|
-
expect(reducers.byTag.get(
|
|
143
|
+
yield* Scope.close(scope, Exit.succeed(undefined))
|
|
144
|
+
expect(reducers.entries.length).toBe(baseline)
|
|
145
|
+
expect(reducers.byTag.get('Tick')).toBeUndefined()
|
|
93
146
|
}).pipe(Effect.provide(Engine)),
|
|
94
|
-
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
it.scopedLive('a lazy feature retains its mounted service type', () =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const engineContext = yield* Effect.context<EngineServices>()
|
|
152
|
+
const scope = yield* Scope.make()
|
|
153
|
+
const ctx = yield* mountLazyCounterFeature(engineContext).pipe(
|
|
154
|
+
Effect.provideService(Scope.Scope, scope),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(Context.get(ctx, LazyMountedProbe).value).toBe('lazy')
|
|
158
|
+
expect(Context.getOption(ctx, UnmountedProbe)._tag).toBe('None')
|
|
159
|
+
yield* Scope.close(scope, Exit.succeed(undefined))
|
|
160
|
+
}).pipe(Effect.provide(Engine)),
|
|
161
|
+
)
|
|
@@ -1,81 +1,85 @@
|
|
|
1
|
-
import { Effect,
|
|
2
|
-
import { expect, it } from
|
|
3
|
-
import * as Composition from
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
isFeature,
|
|
9
|
-
isFeatureBinding,
|
|
10
|
-
lazyImport,
|
|
11
|
-
} from "./feature";
|
|
1
|
+
import { Effect, Option } 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 * as Feature from './feature'
|
|
7
|
+
import { featureModule, isFeature, isFeatureBinding, lazyImport } from './feature'
|
|
12
8
|
|
|
13
9
|
// Minimal composition surface to hang features off — definitions only, no logic.
|
|
14
|
-
class TestUi extends ui(
|
|
15
|
-
class TestComp extends Composition.make(
|
|
16
|
-
title:
|
|
10
|
+
class TestUi extends ui('TestUi')<{ props: {}; events: {} }>() {}
|
|
11
|
+
class TestComp extends Composition.make('TestComp', {
|
|
12
|
+
title: 'Test',
|
|
17
13
|
ui: TestUi,
|
|
18
|
-
}) {}
|
|
19
|
-
class FailComp extends Composition.make(
|
|
20
|
-
title:
|
|
14
|
+
})<TestComp>() {}
|
|
15
|
+
class FailComp extends Composition.make('FailComp', {
|
|
16
|
+
title: 'Fail',
|
|
21
17
|
ui: TestUi,
|
|
22
|
-
}) {}
|
|
18
|
+
})<FailComp>() {}
|
|
19
|
+
|
|
20
|
+
const TestLive = Composition.live(TestComp, function* () {
|
|
21
|
+
return mount({ props: {}, slots: {} })
|
|
22
|
+
})
|
|
23
23
|
|
|
24
|
-
// A
|
|
25
|
-
const mod = featureModule([],
|
|
24
|
+
// A valid Feature module owns its declared root Composition service.
|
|
25
|
+
const mod = featureModule([], TestLive)
|
|
26
26
|
|
|
27
|
-
class LazyFeature extends Feature.make(
|
|
28
|
-
loadingStrategy:
|
|
27
|
+
class LazyFeature extends Feature.make('lazyFeature', {
|
|
28
|
+
loadingStrategy: 'lazy',
|
|
29
29
|
composition: TestComp,
|
|
30
30
|
load: lazyImport(() => Promise.resolve({ default: mod })),
|
|
31
31
|
placeholder: { loading: TestComp, failed: FailComp },
|
|
32
32
|
}) {}
|
|
33
33
|
|
|
34
|
-
class
|
|
34
|
+
class SameShapeFeature extends Feature.make('lazyFeature', {
|
|
35
|
+
loadingStrategy: 'lazy',
|
|
36
|
+
composition: TestComp,
|
|
37
|
+
load: lazyImport(() => Promise.resolve({ default: mod })),
|
|
38
|
+
placeholder: { loading: TestComp, failed: FailComp },
|
|
39
|
+
}) {}
|
|
40
|
+
|
|
41
|
+
class EagerFeature extends Feature.make('eagerFeature', {
|
|
35
42
|
composition: TestComp,
|
|
36
43
|
module: mod,
|
|
37
44
|
}) {}
|
|
38
45
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
expect(LazyFeature.manifest.
|
|
44
|
-
expect(
|
|
45
|
-
|
|
46
|
-
)
|
|
47
|
-
expect(Option.getOrThrow(LazyFeature.manifest.placeholder).
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
});
|
|
46
|
+
const LazyNative = Feature.tree<typeof LazyFeature>(LazyFeature.token)
|
|
47
|
+
const EagerNative = Feature.tree<typeof EagerFeature>(EagerFeature.token)
|
|
48
|
+
|
|
49
|
+
it('a lazy feature reflects as a Feature manifest with placeholders, no chunk loaded', () => {
|
|
50
|
+
expect(LazyFeature.manifest.kind).toBe('Feature')
|
|
51
|
+
expect(LazyFeature.manifest.name).toBe('lazyFeature')
|
|
52
|
+
expect(LazyFeature.manifest.strategy).toBe('lazy')
|
|
53
|
+
expect(LazyFeature.manifest.composition.name).toBe('TestComp')
|
|
54
|
+
expect(Option.getOrThrow(LazyFeature.manifest.placeholder).loading.name).toBe('TestComp')
|
|
55
|
+
expect(Option.getOrThrow(LazyFeature.manifest.placeholder).failed.name).toBe('FailComp')
|
|
56
|
+
expect(LazyNative.eagerModule).toBeUndefined()
|
|
57
|
+
})
|
|
52
58
|
|
|
53
|
-
it(
|
|
54
|
-
expect(EagerFeature.manifest.strategy).toBe(
|
|
55
|
-
expect(Option.isNone(EagerFeature.manifest.placeholder)).toBe(true)
|
|
56
|
-
expect(
|
|
57
|
-
})
|
|
59
|
+
it('an eager feature reflects as default strategy with its module available', () => {
|
|
60
|
+
expect(EagerFeature.manifest.strategy).toBe('default')
|
|
61
|
+
expect(Option.isNone(EagerFeature.manifest.placeholder)).toBe(true)
|
|
62
|
+
expect(EagerNative.eagerModule).toBe(mod)
|
|
63
|
+
})
|
|
58
64
|
|
|
59
|
-
it(
|
|
60
|
-
expect(isFeature(LazyFeature)).toBe(true)
|
|
61
|
-
expect(isFeature(TestComp)).toBe(false)
|
|
62
|
-
expect(isFeature(null)).toBe(false)
|
|
63
|
-
expect(isFeatureBinding(
|
|
64
|
-
expect(isFeatureBinding(TestComp)).toBe(false)
|
|
65
|
-
|
|
65
|
+
it('isFeature / isFeatureBinding discriminate', () => {
|
|
66
|
+
expect(isFeature(LazyFeature)).toBe(true)
|
|
67
|
+
expect(isFeature(TestComp)).toBe(false)
|
|
68
|
+
expect(isFeature(null)).toBe(false)
|
|
69
|
+
expect(isFeatureBinding(LazyNative.binding)).toBe(true)
|
|
70
|
+
expect(isFeatureBinding(TestComp)).toBe(false)
|
|
71
|
+
expect(LazyFeature.token).not.toBe(SameShapeFeature.token)
|
|
72
|
+
expect(LazyFeature.token.identity).not.toBe(SameShapeFeature.token.identity)
|
|
73
|
+
})
|
|
66
74
|
|
|
67
|
-
it.live(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// default strategy resolves immediately to its module.
|
|
79
|
-
expect(yield* EagerFeature.load).toBe(mod);
|
|
80
|
-
}),
|
|
81
|
-
);
|
|
75
|
+
it.live('load is an Effect (not a thenable) that yields the module and its requires', () =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
// No `.then` on `load` — it is an Effect, run on the Effect runtime.
|
|
78
|
+
expect('then' in LazyNative.load).toBe(false)
|
|
79
|
+
const loaded = yield* LazyNative.load
|
|
80
|
+
expect(loaded.requires).toEqual([])
|
|
81
|
+
expect(loaded).toBe(mod)
|
|
82
|
+
// default strategy resolves immediately to its module.
|
|
83
|
+
expect(yield* EagerNative.load).toBe(mod)
|
|
84
|
+
}),
|
|
85
|
+
)
|