@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/compose/provide.ts
CHANGED
|
@@ -1,60 +1,201 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Context, Effect, Layer, Predicate } from 'effect'
|
|
2
2
|
import { InvalidProvideTarget } from '../internal/errors'
|
|
3
|
-
import type { CompositionClass } from './composition'
|
|
4
3
|
import {
|
|
4
|
+
type AnyFeatureBinding,
|
|
5
5
|
type EngineServices,
|
|
6
|
-
type
|
|
6
|
+
type FeatureBinding,
|
|
7
7
|
type FeatureRequirement,
|
|
8
8
|
isFeature,
|
|
9
|
-
|
|
9
|
+
tree,
|
|
10
10
|
} from '../feature/feature'
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
11
|
+
import type {
|
|
12
|
+
AnyComposition,
|
|
13
|
+
CompositionClass,
|
|
14
|
+
CompositionId,
|
|
15
|
+
RequiredSlotIdentities,
|
|
16
|
+
} from './composition'
|
|
17
|
+
import type {
|
|
18
|
+
AnyFeatureGraphSummary,
|
|
19
|
+
CompositionChildSummary,
|
|
20
|
+
ExactServiceExclude,
|
|
21
|
+
FeatureSummaryOpenServices,
|
|
22
|
+
FeatureSummarySupportOpenServices,
|
|
23
|
+
} from '../graph/closure'
|
|
24
|
+
import {
|
|
25
|
+
type AnyFeatureSlotTarget,
|
|
26
|
+
type AnySlot,
|
|
27
|
+
isSlot,
|
|
28
|
+
type SlotClass,
|
|
29
|
+
type SlotService,
|
|
30
|
+
type SlotTarget,
|
|
31
|
+
} from './slot'
|
|
32
|
+
import {
|
|
33
|
+
type CallableView,
|
|
34
|
+
type Contract,
|
|
35
|
+
type AnyUi,
|
|
36
|
+
isUi,
|
|
37
|
+
type UiName,
|
|
38
|
+
type UiContract,
|
|
39
|
+
type UiService,
|
|
40
|
+
type ViewImpl,
|
|
41
|
+
} from './ui'
|
|
42
|
+
|
|
43
|
+
type TargetOfSlotClass<Slot extends AnySlot> = InstanceType<Slot>['_slot']
|
|
44
|
+
type NameOfSlotClass<Slot extends AnySlot> = InstanceType<Slot>['_slotName']
|
|
45
|
+
type IdentityOfSlotClass<Slot extends AnySlot> = InstanceType<Slot>['_slotIdentity']
|
|
46
|
+
type FeatureTargetOfSlotClass<Slot extends AnySlot> =
|
|
47
|
+
TargetOfSlotClass<Slot> extends AnyFeatureSlotTarget ? TargetOfSlotClass<Slot> : never
|
|
48
|
+
|
|
49
|
+
type CompositionIdentityOf<Composition extends AnyComposition> =
|
|
50
|
+
InstanceType<Composition>['_compositionIdentity']
|
|
51
|
+
|
|
52
|
+
// Preserve exact residual inputs that Layer.provideMerge's union subtraction hides.
|
|
53
|
+
export const provideMergeExact =
|
|
54
|
+
<Provided, ProviderError, ProviderInput>(
|
|
55
|
+
provider: Layer.Layer<Provided, ProviderError, ProviderInput>,
|
|
56
|
+
) =>
|
|
57
|
+
<Output, Error, Input>(
|
|
58
|
+
consumer: Layer.Layer<Output, Error, Input>,
|
|
59
|
+
): Layer.Layer<
|
|
60
|
+
Provided | Output,
|
|
61
|
+
ProviderError | Error,
|
|
62
|
+
ProviderInput | Exclude<Input, Provided> | ExactServiceExclude<Input, Provided>
|
|
63
|
+
> =>
|
|
64
|
+
Layer.merge(
|
|
65
|
+
Layer.provideMerge(provider)(consumer),
|
|
66
|
+
Layer.effectDiscard(Effect.context<ExactServiceExclude<Input, Provided>>()),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const compositionSlotLayer = <
|
|
70
|
+
Target extends SlotTarget,
|
|
71
|
+
SlotName extends string,
|
|
72
|
+
SlotIdentity,
|
|
73
|
+
P,
|
|
74
|
+
C extends UiContract,
|
|
75
|
+
S extends ReadonlyArray<unknown>,
|
|
76
|
+
CompositionName extends string,
|
|
77
|
+
CompositionIdentity,
|
|
28
78
|
>(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
79
|
+
slotDefinition: SlotClass<Target, SlotName, SlotIdentity>,
|
|
80
|
+
composition: CompositionClass<P, C, S, CompositionName, CompositionIdentity>,
|
|
81
|
+
): Layer.Layer<SlotService<SlotName, SlotIdentity, CompositionChildSummary<CompositionIdentity>>> =>
|
|
82
|
+
Layer.succeed(
|
|
83
|
+
Context.GenericTag<
|
|
84
|
+
SlotService<SlotName, SlotIdentity, CompositionChildSummary<CompositionIdentity>>,
|
|
85
|
+
AnyComposition
|
|
86
|
+
>(slotDefinition.tag.key),
|
|
87
|
+
composition,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
export const provideExisting = <
|
|
91
|
+
P,
|
|
92
|
+
C extends UiContract,
|
|
93
|
+
S extends ReadonlyArray<unknown>,
|
|
94
|
+
CompositionName extends string,
|
|
95
|
+
CompositionIdentity,
|
|
96
|
+
SlotName extends string,
|
|
97
|
+
SlotIdentity,
|
|
98
|
+
>(
|
|
99
|
+
hole: SlotClass<
|
|
100
|
+
CompositionClass<P, C, S, CompositionName, CompositionIdentity>,
|
|
101
|
+
SlotName,
|
|
102
|
+
SlotIdentity
|
|
36
103
|
>,
|
|
104
|
+
composition: NoInfer<CompositionClass<P, C, S, CompositionName, CompositionIdentity>>,
|
|
105
|
+
): Layer.Layer<
|
|
106
|
+
SlotService<SlotName, SlotIdentity, CompositionChildSummary<CompositionIdentity>>,
|
|
107
|
+
never,
|
|
108
|
+
CompositionId<CompositionName, CompositionIdentity, RequiredSlotIdentities<C>>
|
|
109
|
+
> =>
|
|
110
|
+
Layer.effect(
|
|
111
|
+
Context.GenericTag<
|
|
112
|
+
SlotService<SlotName, SlotIdentity, CompositionChildSummary<CompositionIdentity>>,
|
|
113
|
+
CompositionClass<P, C, S, CompositionName, CompositionIdentity>
|
|
114
|
+
>(hole.tag.key),
|
|
115
|
+
Effect.as(composition.tag, composition),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const featureSlotLayer = <
|
|
119
|
+
Target extends SlotTarget,
|
|
120
|
+
SlotName extends string,
|
|
121
|
+
SlotIdentity,
|
|
122
|
+
Summary extends AnyFeatureGraphSummary,
|
|
123
|
+
ROut,
|
|
124
|
+
DirectRequires extends ReadonlyArray<FeatureRequirement>,
|
|
125
|
+
>(
|
|
126
|
+
slotDefinition: SlotClass<Target, SlotName, SlotIdentity>,
|
|
127
|
+
binding: FeatureBinding<Summary, ROut, DirectRequires>,
|
|
128
|
+
): Layer.Layer<SlotService<SlotName, SlotIdentity, Summary>> =>
|
|
129
|
+
Layer.succeed(
|
|
130
|
+
Context.GenericTag<SlotService<SlotName, SlotIdentity, Summary>, AnyFeatureBinding>(
|
|
131
|
+
slotDefinition.tag.key,
|
|
132
|
+
),
|
|
133
|
+
binding,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const isComposition = (candidate: unknown): candidate is AnyComposition =>
|
|
137
|
+
(typeof candidate === 'function' || typeof candidate === 'object') &&
|
|
138
|
+
candidate !== null &&
|
|
139
|
+
Predicate.hasProperty(candidate, 'manifest') &&
|
|
140
|
+
Predicate.isRecord(candidate.manifest) &&
|
|
141
|
+
candidate.manifest['kind'] === 'Composition' &&
|
|
142
|
+
Predicate.hasProperty(candidate, 'capture') &&
|
|
143
|
+
typeof candidate.capture === 'function'
|
|
144
|
+
|
|
145
|
+
const isCallableView = (candidate: unknown): candidate is CallableView =>
|
|
146
|
+
typeof candidate === 'function'
|
|
147
|
+
|
|
148
|
+
export function provide<U extends AnyUi>(
|
|
149
|
+
contract: U,
|
|
150
|
+
view: ViewImpl<Contract<U>>,
|
|
151
|
+
): Layer.Layer<UiService<UiName<U>>>
|
|
152
|
+
export function provide<Comp extends AnyComposition, N extends string, Identity>(
|
|
153
|
+
hole: SlotClass<Comp, N, Identity>,
|
|
154
|
+
composition: NoInfer<Comp>,
|
|
155
|
+
): Layer.Layer<SlotService<N, Identity, CompositionChildSummary<CompositionIdentityOf<Comp>>>>
|
|
156
|
+
export function provide<Slot extends AnySlot>(
|
|
157
|
+
hole: Slot,
|
|
158
|
+
feature: NoInfer<FeatureTargetOfSlotClass<Slot>>,
|
|
37
159
|
): Layer.Layer<
|
|
38
|
-
|
|
160
|
+
SlotService<
|
|
161
|
+
NameOfSlotClass<Slot>,
|
|
162
|
+
IdentityOfSlotClass<Slot>,
|
|
163
|
+
FeatureTargetOfSlotClass<Slot>['summary']
|
|
164
|
+
>,
|
|
39
165
|
never,
|
|
40
|
-
|
|
166
|
+
| EngineServices
|
|
167
|
+
| FeatureSummaryOpenServices<FeatureTargetOfSlotClass<Slot>['summary']>
|
|
168
|
+
| FeatureSummarySupportOpenServices<FeatureTargetOfSlotClass<Slot>['summary']>
|
|
41
169
|
>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (tag === undefined) {
|
|
50
|
-
return Layer.die(new InvalidProvideTarget())
|
|
170
|
+
export function provide(...args: readonly [target: unknown, implementation: unknown]): unknown {
|
|
171
|
+
const [target, implementation] = args
|
|
172
|
+
if (isUi(target)) {
|
|
173
|
+
if (!isCallableView(implementation)) {
|
|
174
|
+
return Layer.die(new InvalidProvideTarget())
|
|
175
|
+
}
|
|
176
|
+
return target.capture<unknown>((contract) => Layer.succeed(contract.provider, implementation))
|
|
51
177
|
}
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
const eager = impl.eagerModule ?? impl.supportModule
|
|
55
|
-
return eager === undefined
|
|
56
|
-
? Layer.succeed(tag, impl.binding)
|
|
57
|
-
: Layer.merge(Layer.succeed(tag, impl.binding), eager.layer)
|
|
178
|
+
if (!isSlot(target)) {
|
|
179
|
+
return Layer.die(new InvalidProvideTarget())
|
|
58
180
|
}
|
|
59
|
-
return
|
|
181
|
+
return target.capture<unknown>((slotDefinition) => {
|
|
182
|
+
if (isFeature(implementation)) {
|
|
183
|
+
const native = tree<typeof implementation>(implementation.token)
|
|
184
|
+
return native.binding.capture<unknown>((featureBinding) => {
|
|
185
|
+
// Lazy features merge only eager support; default features merge their live module.
|
|
186
|
+
const eager = native.eagerModule ?? native.supportModule
|
|
187
|
+
const binding = featureSlotLayer(slotDefinition, featureBinding)
|
|
188
|
+
if (eager === undefined) {
|
|
189
|
+
return binding
|
|
190
|
+
}
|
|
191
|
+
return eager.capture<unknown>((module) => Layer.merge(binding, module.layer))
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
if (!isComposition(implementation)) {
|
|
195
|
+
return Layer.die(new InvalidProvideTarget())
|
|
196
|
+
}
|
|
197
|
+
return implementation.capture<unknown>((composition) =>
|
|
198
|
+
compositionSlotLayer(slotDefinition, composition),
|
|
199
|
+
)
|
|
200
|
+
})
|
|
60
201
|
}
|
package/src/compose/slot.ts
CHANGED
|
@@ -1,40 +1,177 @@
|
|
|
1
|
-
import { Context } from 'effect'
|
|
2
|
-
import { definitionClass } from '../definition/definition'
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
1
|
+
import { Context, GlobalValue, MutableRef } from 'effect'
|
|
2
|
+
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
|
+
import type { AnyFeatureBinding, ProvideableFeature } from '../feature/feature'
|
|
4
|
+
import type {
|
|
5
|
+
AnyFeatureGraphSummary,
|
|
6
|
+
FeatureSummaryCompositionIdentity,
|
|
7
|
+
FeatureSummaryCompositionProps,
|
|
8
|
+
FeatureSummaryContract,
|
|
9
|
+
FeatureSummaryMountProps,
|
|
10
|
+
FeatureSummaryName,
|
|
11
|
+
FeatureSummaryStates,
|
|
12
|
+
FeatureSummaryIdentity,
|
|
13
|
+
SlotBindingSummary,
|
|
14
|
+
} from '../graph/closure'
|
|
5
15
|
import type { Node } from '../ui/node'
|
|
6
|
-
import type {
|
|
16
|
+
import type { AnyComposition, CompositionClass } from './composition'
|
|
7
17
|
|
|
8
18
|
export type { Node }
|
|
9
19
|
|
|
10
|
-
type AnyComposition
|
|
20
|
+
export type { AnyComposition } from './composition'
|
|
11
21
|
|
|
12
|
-
|
|
22
|
+
type AnyProvideableFeature = ProvideableFeature<unknown, AnyFeatureGraphSummary>
|
|
13
23
|
|
|
14
|
-
export
|
|
15
|
-
|
|
24
|
+
export type FeatureSlotTarget<Feature extends AnyProvideableFeature> = ProvideableFeature<
|
|
25
|
+
FeatureSummaryIdentity<Feature['summary']>,
|
|
26
|
+
Feature['summary']
|
|
27
|
+
>
|
|
28
|
+
|
|
29
|
+
export type AnyFeatureSlotTarget = FeatureSlotTarget<AnyProvideableFeature>
|
|
30
|
+
|
|
31
|
+
export type SlotTarget = AnyComposition | AnyFeatureSlotTarget
|
|
32
|
+
|
|
33
|
+
export type CompositionOfSlotTarget<Target extends SlotTarget> = Target extends AnyComposition
|
|
34
|
+
? Target
|
|
35
|
+
: Target extends AnyFeatureSlotTarget
|
|
36
|
+
? CompositionClass<
|
|
37
|
+
FeatureSummaryCompositionProps<Target['summary']>,
|
|
38
|
+
FeatureSummaryContract<Target['summary']>,
|
|
39
|
+
FeatureSummaryStates<Target['summary']>,
|
|
40
|
+
FeatureSummaryName<Target['summary']>,
|
|
41
|
+
FeatureSummaryCompositionIdentity<Target['summary']>
|
|
42
|
+
>
|
|
43
|
+
: never
|
|
44
|
+
|
|
45
|
+
export type PropsOfSlotTarget<Target extends SlotTarget> = Target extends AnyComposition
|
|
46
|
+
? Target['Props']
|
|
47
|
+
: Target extends AnyFeatureSlotTarget
|
|
48
|
+
? FeatureSummaryMountProps<Target['summary']>
|
|
49
|
+
: never
|
|
50
|
+
|
|
51
|
+
export type ContractOfSlotTarget<Target extends SlotTarget> = Target extends AnyComposition
|
|
52
|
+
? Target['Contract']
|
|
53
|
+
: Target extends AnyFeatureSlotTarget
|
|
54
|
+
? FeatureSummaryContract<Target['summary']>
|
|
55
|
+
: never
|
|
56
|
+
|
|
57
|
+
export type SlotChild<Target extends SlotTarget> = Target extends AnyComposition
|
|
58
|
+
? Target | (AnyFeatureBinding & { readonly composition: Target })
|
|
59
|
+
: Target extends AnyFeatureSlotTarget
|
|
60
|
+
? AnyFeatureBinding & {
|
|
61
|
+
readonly summary: Target['summary']
|
|
62
|
+
}
|
|
63
|
+
: never
|
|
64
|
+
|
|
65
|
+
export type AnySlotChild = AnyComposition | AnyFeatureBinding
|
|
66
|
+
|
|
67
|
+
export interface AnySlotInstance {
|
|
68
|
+
readonly _slot: SlotTarget
|
|
69
|
+
readonly _slotName: string
|
|
70
|
+
readonly _slotIdentity: unknown
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SlotInstance<
|
|
74
|
+
Target extends SlotTarget,
|
|
75
|
+
N extends string,
|
|
76
|
+
Identity,
|
|
77
|
+
> extends AnySlotInstance {
|
|
78
|
+
readonly _slot: Target
|
|
79
|
+
readonly _slotName: N
|
|
80
|
+
readonly _slotIdentity: Identity
|
|
16
81
|
}
|
|
17
82
|
|
|
18
83
|
export const SlotTypeId: unique symbol = Symbol.for('reform/Slot')
|
|
19
84
|
export type SlotTypeId = typeof SlotTypeId
|
|
20
85
|
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
86
|
+
export const SlotServiceTypeId: unique symbol = Symbol.for('reform/SlotService')
|
|
87
|
+
export type SlotServiceTypeId = typeof SlotServiceTypeId
|
|
88
|
+
|
|
89
|
+
export interface SlotService<
|
|
90
|
+
N extends string,
|
|
91
|
+
Identity = unknown,
|
|
92
|
+
out Binding extends SlotBindingSummary = SlotBindingSummary,
|
|
93
|
+
> {
|
|
94
|
+
readonly [SlotServiceTypeId]: {
|
|
95
|
+
readonly name: N
|
|
96
|
+
readonly identity: Identity
|
|
97
|
+
readonly binding: Binding
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SlotManifest<N extends string = string> extends Manifest {
|
|
24
102
|
readonly kind: 'Slot'
|
|
25
|
-
readonly
|
|
103
|
+
readonly name: N
|
|
26
104
|
}
|
|
27
105
|
|
|
28
|
-
export
|
|
106
|
+
export type SlotCapture<Result> = <Target extends SlotTarget, N extends string, Identity>(
|
|
107
|
+
slotDefinition: SlotClass<Target, N, Identity>,
|
|
108
|
+
) => Result
|
|
109
|
+
|
|
110
|
+
export interface AnySlot {
|
|
111
|
+
new (): AnySlotInstance
|
|
112
|
+
readonly [SlotTypeId]: SlotTypeId
|
|
113
|
+
readonly manifest: SlotManifest
|
|
114
|
+
readonly identity: symbol
|
|
115
|
+
readonly capture: <Result>(visit: SlotCapture<Result>) => Result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SlotClass<Target extends SlotTarget, N extends string, Identity> {
|
|
119
|
+
new (): SlotInstance<Target, N, Identity>
|
|
120
|
+
readonly [SlotTypeId]: SlotTypeId
|
|
121
|
+
readonly manifest: SlotManifest<N>
|
|
122
|
+
readonly identity: symbol
|
|
123
|
+
readonly capture: <Result>(visit: SlotCapture<Result>) => Result
|
|
124
|
+
readonly tag: Context.Tag<SlotService<N, Identity>, SlotChild<Target>>
|
|
125
|
+
// Same runtime key, widened only after provide's typed overloads validate the child.
|
|
126
|
+
readonly provider: Context.Tag<SlotService<N, Identity>, AnySlotChild>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const isSlot = (candidate: unknown): candidate is AnySlot =>
|
|
29
130
|
(typeof candidate === 'function' || typeof candidate === 'object') &&
|
|
30
131
|
candidate !== null &&
|
|
31
132
|
SlotTypeId in candidate
|
|
32
133
|
|
|
33
|
-
export type
|
|
134
|
+
export type TargetOfSlot<S> =
|
|
135
|
+
S extends SlotInstance<infer Target, infer _N, infer _Identity> ? Target : never
|
|
136
|
+
|
|
137
|
+
export type SlotProps<S> =
|
|
138
|
+
S extends SlotInstance<infer Target, infer _N, infer _Identity>
|
|
139
|
+
? PropsOfSlotTarget<Target>
|
|
140
|
+
: never
|
|
141
|
+
|
|
142
|
+
export type SlotComposition<S> =
|
|
143
|
+
S extends SlotInstance<infer Target, infer _N, infer _Identity>
|
|
144
|
+
? CompositionOfSlotTarget<Target>
|
|
145
|
+
: never
|
|
146
|
+
|
|
147
|
+
export type SlotContract<S> =
|
|
148
|
+
S extends SlotInstance<infer Target, infer _N, infer _Identity>
|
|
149
|
+
? ContractOfSlotTarget<Target>
|
|
150
|
+
: never
|
|
151
|
+
|
|
152
|
+
const slotDefinitionSequence: MutableRef.MutableRef<number> = GlobalValue.globalValue(
|
|
153
|
+
Symbol.for('reform/SlotDefinitionSequence'),
|
|
154
|
+
() => MutableRef.make(0),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const nextSlotTagKey = (name: string): string =>
|
|
158
|
+
`reform/slot/${name}/${MutableRef.incrementAndGet(slotDefinitionSequence)}`
|
|
34
159
|
|
|
35
160
|
export const slot =
|
|
36
|
-
(name:
|
|
37
|
-
<
|
|
38
|
-
const
|
|
39
|
-
|
|
161
|
+
<const N extends string>(name: N) =>
|
|
162
|
+
<Self, Target extends SlotTarget = AnyComposition>(): SlotClass<Target, N, Self> => {
|
|
163
|
+
const key = nextSlotTagKey(name)
|
|
164
|
+
const identity = Symbol(name)
|
|
165
|
+
const tag = Context.GenericTag<SlotService<N, Self>, SlotChild<Target>>(key)
|
|
166
|
+
const provider = Context.GenericTag<SlotService<N, Self>, AnySlotChild>(key)
|
|
167
|
+
const manifest: SlotManifest<N> = { kind: 'Slot', name }
|
|
168
|
+
const slotDefinition: SlotClass<Target, N, Self> = definitionClass<SlotClass<Target, N, Self>>({
|
|
169
|
+
[SlotTypeId]: SlotTypeId,
|
|
170
|
+
manifest,
|
|
171
|
+
identity,
|
|
172
|
+
tag,
|
|
173
|
+
provider,
|
|
174
|
+
capture: <Result>(visit: SlotCapture<Result>): Result => visit(slotDefinition),
|
|
175
|
+
})
|
|
176
|
+
return slotDefinition
|
|
40
177
|
}
|
|
@@ -12,8 +12,8 @@ class Row extends Composition.make('Row', {
|
|
|
12
12
|
title: 'Row',
|
|
13
13
|
props: S.Struct({ label: S.String }),
|
|
14
14
|
ui: RowUi,
|
|
15
|
-
}) {}
|
|
16
|
-
class RowSlot extends slot('Row')<typeof Row>() {}
|
|
15
|
+
})<Row>() {}
|
|
16
|
+
class RowSlot extends slot('Row')<RowSlot, typeof Row>() {}
|
|
17
17
|
|
|
18
18
|
class ListUi extends ui('List')<{
|
|
19
19
|
props: { total: number }
|
|
@@ -22,7 +22,10 @@ class ListUi extends ui('List')<{
|
|
|
22
22
|
|
|
23
23
|
describe('structure combinators', () => {
|
|
24
24
|
test('each produces one keyed entry per item, props mapped', () => {
|
|
25
|
-
const fill = each([{ id: 'a' }, { id: 'b' }], {
|
|
25
|
+
const fill = each([{ id: 'a' }, { id: 'b' }], {
|
|
26
|
+
key: (t) => t.id,
|
|
27
|
+
props: (t) => ({ label: t.id }),
|
|
28
|
+
})
|
|
26
29
|
expect(fill).toEqual({
|
|
27
30
|
_tag: 'Each',
|
|
28
31
|
items: [
|
package/src/compose/structure.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Struct } from 'effect'
|
|
1
|
+
import { Option, Struct } from 'effect'
|
|
2
|
+
import { reuse } from '../internal/reuse'
|
|
2
3
|
import type { SlotProps } from './slot'
|
|
3
4
|
import type { EventsOf, UiContract } from './ui'
|
|
4
5
|
|
|
@@ -22,37 +23,129 @@ export type StructureSlots<C extends UiContract> = C extends {
|
|
|
22
23
|
slots: infer S
|
|
23
24
|
}
|
|
24
25
|
? { readonly [K in keyof S]: SlotFill<SlotProps<S[K]>> }
|
|
25
|
-
: Record<
|
|
26
|
+
: Readonly<Record<PropertyKey, never>>
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
export interface Structure<C extends UiContract> {
|
|
28
|
+
interface SerializableFrame<C extends UiContract> {
|
|
29
29
|
readonly props: C['props']
|
|
30
30
|
readonly slots: StructureSlots<C>
|
|
31
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- serializable frame shape; triggers ride optionally and the field is intentionally omitted from structureEquals
|
|
32
|
-
readonly events?: EventsOf<C>
|
|
33
31
|
}
|
|
34
32
|
|
|
33
|
+
interface TriggeredFrame<C extends UiContract> extends SerializableFrame<C> {
|
|
34
|
+
readonly events: EventsOf<C>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type MountFrame<C extends UiContract> = SerializableFrame<C> | TriggeredFrame<C>
|
|
38
|
+
|
|
35
39
|
export const StructureTypeId: unique symbol = Symbol.for('reform/Structure')
|
|
36
40
|
export type StructureTypeId = typeof StructureTypeId
|
|
37
41
|
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
export interface Structure<C extends UiContract> {
|
|
43
|
+
readonly [StructureTypeId]: StructureTypeId
|
|
44
|
+
readonly props: C['props']
|
|
45
|
+
readonly slots: StructureSlots<C>
|
|
46
|
+
readonly events: EventsOf<C> | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const frameEvents = <C extends UiContract>(frame: MountFrame<C>): Option.Option<EventsOf<C>> =>
|
|
50
|
+
'events' in frame ? Option.some(frame.events) : Option.none<EventsOf<C>>()
|
|
51
|
+
|
|
52
|
+
export const mount = <C extends UiContract>(frame: MountFrame<C>): Structure<C> => ({
|
|
53
|
+
events: Option.getOrUndefined(frameEvents(frame)),
|
|
54
|
+
props: frame.props,
|
|
55
|
+
slots: frame.slots,
|
|
56
|
+
[StructureTypeId]: StructureTypeId,
|
|
57
|
+
})
|
|
41
58
|
|
|
42
|
-
// key is the single source for both wire key and per-item StateFamily key
|
|
43
59
|
export interface EachOptions<T, P> {
|
|
44
60
|
readonly key: (item: T) => string
|
|
45
61
|
readonly props: (item: T) => P
|
|
46
62
|
}
|
|
47
63
|
|
|
48
|
-
|
|
64
|
+
interface EachRetention {
|
|
65
|
+
readonly elements: ReadonlyArray<unknown>
|
|
66
|
+
readonly fill: unknown
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface CollectionRetentions {
|
|
70
|
+
readonly byProps: WeakMap<object, WeakMap<object, EachRetention>>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Stable collection and callback identities retain unchanged fills by reference.
|
|
74
|
+
const eachRetentions = new WeakMap<object, CollectionRetentions>()
|
|
75
|
+
const latestEachRetentions = new WeakMap<object, EachRetention>()
|
|
76
|
+
|
|
77
|
+
const hasIdentity = (candidate: unknown): candidate is object =>
|
|
78
|
+
(typeof candidate === 'object' && candidate !== null) || typeof candidate === 'function'
|
|
79
|
+
|
|
80
|
+
const collectionRetentionsFor = (collection: unknown): CollectionRetentions | undefined => {
|
|
81
|
+
if (!hasIdentity(collection)) {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
return (
|
|
85
|
+
eachRetentions.get(collection) ?? {
|
|
86
|
+
byProps: new WeakMap<object, WeakMap<object, EachRetention>>(),
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sameElements = (previous: ReadonlyArray<unknown>, next: ReadonlyArray<unknown>): boolean =>
|
|
92
|
+
previous.length === next.length &&
|
|
93
|
+
previous.every((element, index) => Object.is(element, next[index]))
|
|
94
|
+
|
|
95
|
+
const isEachFill = <P>(candidate: unknown): candidate is EachFill<P> =>
|
|
96
|
+
isRecord(candidate) &&
|
|
97
|
+
candidate['_tag'] === 'Each' &&
|
|
98
|
+
Array.isArray(candidate['items']) &&
|
|
99
|
+
candidate['items'].every(
|
|
100
|
+
(entry) => isRecord(entry) && typeof entry['key'] === 'string' && 'props' in entry,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const materializeEach = <T, P>(
|
|
104
|
+
elements: ReadonlyArray<T>,
|
|
105
|
+
options: EachOptions<T, P>,
|
|
106
|
+
): EachFill<P> => ({
|
|
49
107
|
_tag: 'Each',
|
|
50
|
-
items:
|
|
108
|
+
items: elements.map((element) => ({
|
|
51
109
|
key: options.key(element),
|
|
52
110
|
props: options.props(element),
|
|
53
111
|
})),
|
|
54
112
|
})
|
|
55
113
|
|
|
114
|
+
const retainEach = <P>(previous: unknown, next: EachFill<P>): EachFill<P> => {
|
|
115
|
+
if (!isEachFill<P>(previous)) {
|
|
116
|
+
return next
|
|
117
|
+
}
|
|
118
|
+
if (deepEquals({ left: previous, right: next })) {
|
|
119
|
+
return previous
|
|
120
|
+
}
|
|
121
|
+
return reuse(previous, next)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const each = <T, P>(collection: Iterable<T>, options: EachOptions<T, P>): EachFill<P> => {
|
|
125
|
+
const elements = Array.from(collection)
|
|
126
|
+
const collectionRetentions = collectionRetentionsFor(collection)
|
|
127
|
+
const retained = collectionRetentions?.byProps.get(options.props)?.get(options.key)
|
|
128
|
+
const latest = hasIdentity(collection) ? latestEachRetentions.get(collection) : undefined
|
|
129
|
+
if (
|
|
130
|
+
retained !== undefined &&
|
|
131
|
+
sameElements(retained.elements, elements) &&
|
|
132
|
+
isEachFill<P>(retained.fill)
|
|
133
|
+
) {
|
|
134
|
+
return retained.fill
|
|
135
|
+
}
|
|
136
|
+
const fill = retainEach(retained?.fill ?? latest?.fill, materializeEach(elements, options))
|
|
137
|
+
if (hasIdentity(collection) && collectionRetentions !== undefined) {
|
|
138
|
+
const byKey =
|
|
139
|
+
collectionRetentions.byProps.get(options.props) ?? new WeakMap<object, EachRetention>()
|
|
140
|
+
const retention = { elements, fill }
|
|
141
|
+
byKey.set(options.key, retention)
|
|
142
|
+
collectionRetentions.byProps.set(options.props, byKey)
|
|
143
|
+
eachRetentions.set(collection, collectionRetentions)
|
|
144
|
+
latestEachRetentions.set(collection, retention)
|
|
145
|
+
}
|
|
146
|
+
return fill
|
|
147
|
+
}
|
|
148
|
+
|
|
56
149
|
export const one = <P>(props: P): OneFill<P> => ({ _tag: 'One', props })
|
|
57
150
|
|
|
58
151
|
export const absent: AbsentFill = { _tag: 'Absent' }
|
|
@@ -96,10 +189,10 @@ const deepEquals = ({ left, right }: ValuePair): boolean => {
|
|
|
96
189
|
return false
|
|
97
190
|
}
|
|
98
191
|
|
|
99
|
-
// events intentionally excluded (fresh function identities would break settle fixpoint)
|
|
100
192
|
export const structureEquals = (
|
|
101
193
|
left: Structure<UiContract>,
|
|
102
194
|
right: Structure<UiContract>,
|
|
103
195
|
): boolean =>
|
|
196
|
+
// Events are fresh trigger functions, so equality covers serializable frame data only.
|
|
104
197
|
deepEquals({ left: left.props, right: right.props }) &&
|
|
105
198
|
deepEquals({ left: left.slots, right: right.slots })
|
package/src/compose/ui.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { expect, test } from 'vitest'
|
|
2
|
-
import { Schema as S } from 'effect'
|
|
3
|
-
import { isUi, ui } from '../index'
|
|
1
|
+
import { expect, it, test } from '@effect/vitest'
|
|
2
|
+
import { Effect, Layer, Schema as S } from 'effect'
|
|
3
|
+
import { isUi, provide, Ui, ui } from '../index'
|
|
4
4
|
|
|
5
5
|
test('type-only `ui` carries no wire schema', () => {
|
|
6
6
|
class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
|
|
@@ -28,3 +28,19 @@ test('schema `ui` with no events omits the events schema', () => {
|
|
|
28
28
|
expect(S.isSchema(TitleUi.manifest.props)).toBe(true)
|
|
29
29
|
expect(TitleUi.manifest.events).toBeUndefined()
|
|
30
30
|
})
|
|
31
|
+
|
|
32
|
+
it.live('same-shaped UI definitions resolve their own views', () => {
|
|
33
|
+
class FirstUi extends ui('UiNominalFirst')<{ props: { label: string } }>() {}
|
|
34
|
+
class SecondUi extends ui('UiNominalSecond')<{ props: { label: string } }>() {}
|
|
35
|
+
const FirstView = Ui.make(FirstUi, ({ label }) => `first:${label}`)
|
|
36
|
+
const SecondView = Ui.make(SecondUi, ({ label }) => `second:${label}`)
|
|
37
|
+
const Live = Layer.mergeAll(provide(FirstUi, FirstView), provide(SecondUi, SecondView))
|
|
38
|
+
|
|
39
|
+
return Effect.gen(function* () {
|
|
40
|
+
const first = yield* FirstUi.impl
|
|
41
|
+
const second = yield* SecondUi.impl
|
|
42
|
+
|
|
43
|
+
expect(first({ label: 'value' }, {}, {})).toBe('first:value')
|
|
44
|
+
expect(second({ label: 'value' }, {}, {})).toBe('second:value')
|
|
45
|
+
}).pipe(Effect.provide(Live))
|
|
46
|
+
})
|