@playfast/reform 0.0.7 → 0.0.9
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/package.json +2 -2
- package/src/boundary/boundary.test.ts +301 -0
- package/src/boundary/boundary.ts +192 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncCalc.ts +267 -0
- package/src/calc/asyncData.ts +96 -0
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calc.ts +120 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/calcFamily.ts +194 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/calc/compose.ts +69 -0
- package/src/channel/channel.ts +269 -0
- package/src/compose/composition.ts +146 -0
- package/src/compose/host.ts +12 -0
- package/{dist/compose/props.js → src/compose/props.ts} +7 -5
- package/src/compose/provide.ts +70 -0
- package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
- package/src/compose/structure.test.ts +90 -0
- package/src/compose/structure.ts +145 -0
- package/src/compose/ui.test.ts +34 -0
- package/src/compose/ui.ts +204 -0
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +76 -0
- package/src/event/event.test.ts +23 -0
- package/src/event/event.ts +65 -0
- package/src/event/eventGroup.ts +14 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +191 -0
- package/src/internal/capture.ts +38 -0
- package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +133 -0
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/inspect.ts +34 -0
- package/src/internal/queryDriver.ts +247 -0
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/reuse.ts +73 -0
- package/src/internal/scheduler.ts +93 -0
- package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
- package/src/internal/sources.ts +104 -0
- package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
- package/src/internal/store.test.ts +80 -0
- package/src/internal/store.ts +105 -0
- package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
- package/src/procedure/procedure.ts +89 -0
- package/src/reducer/reducer.ts +137 -0
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.ts +571 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +40 -0
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +171 -0
- package/src/scene/scene.ts +88 -0
- package/src/scene/seedScene.test.ts +176 -0
- package/src/state/state.ts +94 -0
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +174 -0
- package/src/state/stateGroup.ts +125 -0
- package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
- package/src/synced/syncedStore.ts +99 -0
- package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
- package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
- package/dist/boundary/boundary.d.ts +0 -72
- package/dist/boundary/boundary.d.ts.map +0 -1
- package/dist/boundary/boundary.js +0 -83
- package/dist/boundary/boundary.js.map +0 -1
- package/dist/calc/asyncCalc.d.ts +0 -91
- package/dist/calc/asyncCalc.d.ts.map +0 -1
- package/dist/calc/asyncCalc.js +0 -95
- package/dist/calc/asyncCalc.js.map +0 -1
- package/dist/calc/asyncData.d.ts +0 -57
- package/dist/calc/asyncData.d.ts.map +0 -1
- package/dist/calc/asyncData.js +0 -34
- package/dist/calc/asyncData.js.map +0 -1
- package/dist/calc/calc.d.ts +0 -57
- package/dist/calc/calc.d.ts.map +0 -1
- package/dist/calc/calc.js +0 -58
- package/dist/calc/calc.js.map +0 -1
- package/dist/calc/calcFamily.d.ts +0 -57
- package/dist/calc/calcFamily.d.ts.map +0 -1
- package/dist/calc/calcFamily.js +0 -124
- package/dist/calc/calcFamily.js.map +0 -1
- package/dist/calc/compose.d.ts +0 -16
- package/dist/calc/compose.d.ts.map +0 -1
- package/dist/calc/compose.js +0 -7
- package/dist/calc/compose.js.map +0 -1
- package/dist/channel/channel.d.ts +0 -115
- package/dist/channel/channel.d.ts.map +0 -1
- package/dist/channel/channel.js +0 -138
- package/dist/channel/channel.js.map +0 -1
- package/dist/compose/composition.d.ts +0 -72
- package/dist/compose/composition.d.ts.map +0 -1
- package/dist/compose/composition.js +0 -46
- package/dist/compose/composition.js.map +0 -1
- package/dist/compose/host.d.ts +0 -17
- package/dist/compose/host.d.ts.map +0 -1
- package/dist/compose/host.js +0 -6
- package/dist/compose/host.js.map +0 -1
- package/dist/compose/props.d.ts +0 -13
- package/dist/compose/props.d.ts.map +0 -1
- package/dist/compose/props.js.map +0 -1
- package/dist/compose/provide.d.ts +0 -22
- package/dist/compose/provide.d.ts.map +0 -1
- package/dist/compose/provide.js +0 -28
- package/dist/compose/provide.js.map +0 -1
- package/dist/compose/slot.d.ts.map +0 -1
- package/dist/compose/slot.js +0 -23
- package/dist/compose/slot.js.map +0 -1
- package/dist/compose/ui.d.ts +0 -135
- package/dist/compose/ui.d.ts.map +0 -1
- package/dist/compose/ui.js +0 -63
- package/dist/compose/ui.js.map +0 -1
- package/dist/definition/definition.d.ts +0 -33
- package/dist/definition/definition.d.ts.map +0 -1
- package/dist/definition/definition.js +0 -42
- package/dist/definition/definition.js.map +0 -1
- package/dist/event/event.d.ts +0 -33
- package/dist/event/event.d.ts.map +0 -1
- package/dist/event/event.js +0 -30
- package/dist/event/event.js.map +0 -1
- package/dist/event/eventGroup.d.ts +0 -9
- package/dist/event/eventGroup.d.ts.map +0 -1
- package/dist/event/eventGroup.js +0 -4
- package/dist/event/eventGroup.js.map +0 -1
- package/dist/feature/feature.d.ts.map +0 -1
- package/dist/feature/feature.js +0 -98
- package/dist/feature/feature.js.map +0 -1
- package/dist/index.d.ts +0 -53
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -62
- package/dist/index.js.map +0 -1
- package/dist/internal/capture.d.ts +0 -28
- package/dist/internal/capture.d.ts.map +0 -1
- package/dist/internal/capture.js +0 -12
- package/dist/internal/capture.js.map +0 -1
- package/dist/internal/ctx.d.ts.map +0 -1
- package/dist/internal/ctx.js +0 -2
- package/dist/internal/ctx.js.map +0 -1
- package/dist/internal/errors.d.ts +0 -89
- package/dist/internal/errors.d.ts.map +0 -1
- package/dist/internal/errors.js +0 -102
- package/dist/internal/errors.js.map +0 -1
- package/dist/internal/inspect.d.ts +0 -17
- package/dist/internal/inspect.d.ts.map +0 -1
- package/dist/internal/inspect.js +0 -32
- package/dist/internal/inspect.js.map +0 -1
- package/dist/internal/queryDriver.d.ts +0 -65
- package/dist/internal/queryDriver.d.ts.map +0 -1
- package/dist/internal/queryDriver.js +0 -134
- package/dist/internal/queryDriver.js.map +0 -1
- package/dist/internal/reuse.d.ts +0 -10
- package/dist/internal/reuse.d.ts.map +0 -1
- package/dist/internal/reuse.js +0 -68
- package/dist/internal/reuse.js.map +0 -1
- package/dist/internal/scheduler.d.ts +0 -47
- package/dist/internal/scheduler.d.ts.map +0 -1
- package/dist/internal/scheduler.js +0 -70
- package/dist/internal/scheduler.js.map +0 -1
- package/dist/internal/seeds.d.ts.map +0 -1
- package/dist/internal/seeds.js +0 -17
- package/dist/internal/seeds.js.map +0 -1
- package/dist/internal/sources.d.ts +0 -39
- package/dist/internal/sources.d.ts.map +0 -1
- package/dist/internal/sources.js +0 -59
- package/dist/internal/sources.js.map +0 -1
- package/dist/internal/stateRegistry.d.ts +0 -2
- package/dist/internal/stateRegistry.d.ts.map +0 -1
- package/dist/internal/stateRegistry.js.map +0 -1
- package/dist/internal/store.d.ts +0 -47
- package/dist/internal/store.d.ts.map +0 -1
- package/dist/internal/store.js +0 -73
- package/dist/internal/store.js.map +0 -1
- package/dist/internal/track.d.ts.map +0 -1
- package/dist/internal/track.js +0 -19
- package/dist/internal/track.js.map +0 -1
- package/dist/procedure/procedure.d.ts +0 -40
- package/dist/procedure/procedure.d.ts.map +0 -1
- package/dist/procedure/procedure.js +0 -50
- package/dist/procedure/procedure.js.map +0 -1
- package/dist/reducer/reducer.d.ts +0 -44
- package/dist/reducer/reducer.d.ts.map +0 -1
- package/dist/reducer/reducer.js +0 -63
- package/dist/reducer/reducer.js.map +0 -1
- package/dist/remote/remoteState.d.ts +0 -119
- package/dist/remote/remoteState.d.ts.map +0 -1
- package/dist/remote/remoteState.js +0 -270
- package/dist/remote/remoteState.js.map +0 -1
- package/dist/runtime/bus.d.ts +0 -27
- package/dist/runtime/bus.d.ts.map +0 -1
- package/dist/runtime/bus.js +0 -21
- package/dist/runtime/bus.js.map +0 -1
- package/dist/runtime/loop.d.ts +0 -45
- package/dist/runtime/loop.d.ts.map +0 -1
- package/dist/runtime/loop.js +0 -117
- package/dist/runtime/loop.js.map +0 -1
- package/dist/scene/scene.d.ts +0 -44
- package/dist/scene/scene.d.ts.map +0 -1
- package/dist/scene/scene.js +0 -31
- package/dist/scene/scene.js.map +0 -1
- package/dist/state/state.d.ts +0 -37
- package/dist/state/state.d.ts.map +0 -1
- package/dist/state/state.js +0 -46
- package/dist/state/state.js.map +0 -1
- package/dist/state/stateFamily.d.ts +0 -79
- package/dist/state/stateFamily.d.ts.map +0 -1
- package/dist/state/stateFamily.js +0 -99
- package/dist/state/stateFamily.js.map +0 -1
- package/dist/state/stateGroup.d.ts +0 -50
- package/dist/state/stateGroup.d.ts.map +0 -1
- package/dist/state/stateGroup.js +0 -54
- package/dist/state/stateGroup.js.map +0 -1
- package/dist/state/token.d.ts.map +0 -1
- package/dist/state/token.js +0 -20
- package/dist/state/token.js.map +0 -1
- package/dist/synced/syncedStore.d.ts +0 -51
- package/dist/synced/syncedStore.d.ts.map +0 -1
- package/dist/synced/syncedStore.js +0 -43
- package/dist/synced/syncedStore.js.map +0 -1
- package/dist/ui/node.d.ts.map +0 -1
- package/dist/ui/node.js +0 -2
- package/dist/ui/node.js.map +0 -1
- package/dist/ui/trigger.d.ts.map +0 -1
- package/dist/ui/trigger.js +0 -2
- package/dist/ui/trigger.js.map +0 -1
- package/dist/wire/tree.d.ts +0 -66
- package/dist/wire/tree.d.ts.map +0 -1
- package/dist/wire/tree.js +0 -60
- package/dist/wire/tree.js.map +0 -1
- package/dist/wire/triggers.d.ts +0 -44
- package/dist/wire/triggers.d.ts.map +0 -1
- package/dist/wire/triggers.js +0 -31
- package/dist/wire/triggers.js.map +0 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { Chunk, Context, type Duration, Effect, Layer, Match, Queue, Stream } from 'effect'
|
|
2
|
+
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
|
+
import { DuplicateRegistration } from '../internal/errors'
|
|
4
|
+
import type { Tagged } from '../runtime/bus'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How a channel schedules the procedure work routed to it. Channels generalize
|
|
8
|
+
* the old per-procedure `concurrency`: a named lane with one policy, shared by
|
|
9
|
+
* every procedure that declares it.
|
|
10
|
+
*
|
|
11
|
+
* - `merge` run every routed event concurrently (the default).
|
|
12
|
+
* - `latest` a new event cancels the in-flight run (switch).
|
|
13
|
+
* - `debounce` collapse a burst, run only after `duration` of quiet.
|
|
14
|
+
* - `throttle` rate-limit to `units` per `duration` (shape or enforce).
|
|
15
|
+
* - `exclusive` one run at a time across the whole channel (mutual exclusion).
|
|
16
|
+
*/
|
|
17
|
+
export type ChannelPolicy =
|
|
18
|
+
| { readonly _tag: 'merge' }
|
|
19
|
+
| { readonly _tag: 'latest' }
|
|
20
|
+
| { readonly _tag: 'debounce'; readonly duration: Duration.DurationInput }
|
|
21
|
+
| {
|
|
22
|
+
readonly _tag: 'throttle'
|
|
23
|
+
readonly units: number
|
|
24
|
+
readonly duration: Duration.DurationInput
|
|
25
|
+
readonly cost?: number
|
|
26
|
+
readonly burst?: number
|
|
27
|
+
readonly strategy?: 'enforce' | 'shape'
|
|
28
|
+
}
|
|
29
|
+
| { readonly _tag: 'exclusive' }
|
|
30
|
+
|
|
31
|
+
/** The reflectable descriptor a channel carries. */
|
|
32
|
+
export interface ChannelManifest extends Manifest {
|
|
33
|
+
readonly kind: 'Channel'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ChannelClass {
|
|
37
|
+
new (): {}
|
|
38
|
+
readonly manifest: ChannelManifest
|
|
39
|
+
readonly policy: ChannelPolicy
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ChannelConfig {
|
|
43
|
+
readonly policy: ChannelPolicy
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A registered procedure body, with its trigger events and the channel it runs
|
|
48
|
+
* on. `run`'s context (RPC clients, etc.) is already provided at registration
|
|
49
|
+
* (see `Procedure.live`), so the channel fiber can run it with no R.
|
|
50
|
+
*/
|
|
51
|
+
export interface ProcedureEntry {
|
|
52
|
+
/** The procedure's name — used only to warn on duplicate registration. */
|
|
53
|
+
readonly name: string
|
|
54
|
+
readonly channelName: string
|
|
55
|
+
readonly handles: ReadonlySet<string>
|
|
56
|
+
readonly run: (event: Tagged) => Effect.Effect<unknown, unknown, never>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mutable collector procedures register into; channels read it to find their
|
|
61
|
+
* bodies. `byTag` indexes entries by trigger tag, so a channel routes an event
|
|
62
|
+
* to O(procedures handling it) rather than scanning every registered procedure.
|
|
63
|
+
*/
|
|
64
|
+
export interface ProcedureRegistry {
|
|
65
|
+
readonly entries: Array<ProcedureEntry>
|
|
66
|
+
readonly byTag: Map<string, Array<ProcedureEntry>>
|
|
67
|
+
/**
|
|
68
|
+
* Distinct channel names per event tag. The drain loop routes through this so
|
|
69
|
+
* it offers each event to each channel exactly once — not once per matching
|
|
70
|
+
* procedure, which would re-run every procedure on a shared channel per offer.
|
|
71
|
+
*/
|
|
72
|
+
readonly channelsByTag: Map<string, Array<string>>
|
|
73
|
+
/**
|
|
74
|
+
* Procedures keyed by (channel, tag), so a channel's `runMatching` is a direct
|
|
75
|
+
* lookup instead of scanning `byTag` and re-filtering by channel per event.
|
|
76
|
+
*/
|
|
77
|
+
readonly byChannelTag: Map<string, Map<string, Array<ProcedureEntry>>>
|
|
78
|
+
readonly register: (entry: ProcedureEntry) => void
|
|
79
|
+
/**
|
|
80
|
+
* Remove a previously-registered entry — the inverse of `register`, used when a
|
|
81
|
+
* lazy feature unmounts (scope close) so its procedures stop running and their
|
|
82
|
+
* index entries are reclaimed. Eager procedures (root scope) never call it.
|
|
83
|
+
*/
|
|
84
|
+
readonly unregister: (entry: ProcedureEntry) => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ProceduresBase: Context.TagClass<Procedures, 'reform/Procedures', ProcedureRegistry> =
|
|
88
|
+
Context.Tag('reform/Procedures')<Procedures, ProcedureRegistry>()
|
|
89
|
+
export class Procedures extends ProceduresBase {}
|
|
90
|
+
|
|
91
|
+
/** Get a map entry, creating and inserting it on first access (avoids `let`). */
|
|
92
|
+
const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
|
|
93
|
+
const existing = map.get(key)
|
|
94
|
+
if (existing !== undefined) return existing
|
|
95
|
+
const created = make()
|
|
96
|
+
map.set(key, created)
|
|
97
|
+
return created
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drop an item from a `Map<K, Array>` bucket, pruning the key when it empties. */
|
|
101
|
+
const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K, item: T): void => {
|
|
102
|
+
const bucket = map.get(key)
|
|
103
|
+
if (bucket === undefined) return
|
|
104
|
+
const next = bucket.filter((value) => value !== item)
|
|
105
|
+
if (next.length === 0) map.delete(key)
|
|
106
|
+
else map.set(key, next)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const makeProcedureRegistry = (): ProcedureRegistry => {
|
|
110
|
+
const entries: Array<ProcedureEntry> = []
|
|
111
|
+
const byTag = new Map<string, Array<ProcedureEntry>>()
|
|
112
|
+
const channelsByTag = new Map<string, Array<string>>()
|
|
113
|
+
const byChannelTag = new Map<string, Map<string, Array<ProcedureEntry>>>()
|
|
114
|
+
return {
|
|
115
|
+
entries,
|
|
116
|
+
byTag,
|
|
117
|
+
channelsByTag,
|
|
118
|
+
byChannelTag,
|
|
119
|
+
register: (entry) => {
|
|
120
|
+
entries.push(entry)
|
|
121
|
+
for (const tag of entry.handles) {
|
|
122
|
+
getOrCreate(byTag, tag, () => []).push(entry)
|
|
123
|
+
const channels = getOrCreate(channelsByTag, tag, () => [])
|
|
124
|
+
if (!channels.includes(entry.channelName)) channels.push(entry.channelName)
|
|
125
|
+
getOrCreate(getOrCreate(byChannelTag, entry.channelName, () => new Map()), tag, () => []).push(
|
|
126
|
+
entry,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
unregister: (entry) => {
|
|
131
|
+
const index = entries.indexOf(entry)
|
|
132
|
+
if (index >= 0) entries.splice(index, 1)
|
|
133
|
+
for (const tag of entry.handles) {
|
|
134
|
+
dropFrom(byTag, tag, entry)
|
|
135
|
+
const channelMap = byChannelTag.get(entry.channelName)
|
|
136
|
+
if (channelMap !== undefined) {
|
|
137
|
+
dropFrom(channelMap, tag, entry)
|
|
138
|
+
// If no procedure on this channel still handles the tag, drop the
|
|
139
|
+
// channel from `channelsByTag[tag]` so the loop stops offering to it.
|
|
140
|
+
if (channelMap.get(tag) === undefined) dropFrom(channelsByTag, tag, entry.channelName)
|
|
141
|
+
if (channelMap.size === 0) byChannelTag.delete(entry.channelName)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(Procedures, makeProcedureRegistry)
|
|
149
|
+
|
|
150
|
+
/** A live channel: the single loop offers events here; the channel drives them. */
|
|
151
|
+
export interface ChannelRuntime {
|
|
152
|
+
readonly name: string
|
|
153
|
+
/** The policy this channel was built with — used to reject a name clash. */
|
|
154
|
+
readonly policy: ChannelPolicy
|
|
155
|
+
readonly offer: (event: Tagged) => void
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Registry of live channels, keyed by name. The single loop routes through it. */
|
|
159
|
+
const ChannelsBase: Context.TagClass<
|
|
160
|
+
Channels,
|
|
161
|
+
'reform/Channels',
|
|
162
|
+
{ readonly byName: Map<string, ChannelRuntime> }
|
|
163
|
+
> = Context.Tag('reform/Channels')<Channels, { readonly byName: Map<string, ChannelRuntime> }>()
|
|
164
|
+
export class Channels extends ChannelsBase {}
|
|
165
|
+
|
|
166
|
+
export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({ byName: new Map() }))
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A scheduling lane for procedures. The *definition* (`Channel.make`) is a
|
|
170
|
+
* reflectable name + policy; the *implementation* (`Channel.live`) builds the
|
|
171
|
+
* queue + fiber that applies the policy. Procedures reference a channel by value
|
|
172
|
+
* and register into it; `Channel.live` is merged into the app once per channel.
|
|
173
|
+
*/
|
|
174
|
+
export const make = (name: string, config: ChannelConfig): ChannelClass =>
|
|
175
|
+
definitionClass<ChannelClass>({
|
|
176
|
+
manifest: { kind: 'Channel' as const, name } satisfies ChannelManifest,
|
|
177
|
+
policy: config.policy,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Build and register the live machinery for a channel. Idempotent by name, so
|
|
182
|
+
* procedures sharing an `exclusive`/`latest` channel share one queue + fiber +
|
|
183
|
+
* semaphore (and therefore truly serialize / cancel across each other).
|
|
184
|
+
*/
|
|
185
|
+
export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels | Procedures> =>
|
|
186
|
+
Layer.scopedDiscard(
|
|
187
|
+
Effect.gen(function* () {
|
|
188
|
+
const name = channel.manifest.name
|
|
189
|
+
const channels = yield* Channels
|
|
190
|
+
const existing = channels.byName.get(name)
|
|
191
|
+
if (existing !== undefined) {
|
|
192
|
+
// Idempotent for the *same* channel `.live`'d through several sub-layers
|
|
193
|
+
// (shared `exclusive`/`latest` lane). A *different* channel reusing the
|
|
194
|
+
// name is a silent footgun — the second policy would be dropped — so
|
|
195
|
+
// reject it loudly instead.
|
|
196
|
+
if (existing.policy !== channel.policy) {
|
|
197
|
+
throw new DuplicateRegistration({ kind: 'Channel', name })
|
|
198
|
+
}
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
const procedures = yield* Procedures
|
|
202
|
+
const queue = yield* Queue.unbounded<Tagged>()
|
|
203
|
+
|
|
204
|
+
// Run every procedure on THIS channel that handles the event. Read the live
|
|
205
|
+
// index each time so procedures registered later are still seen.
|
|
206
|
+
// A procedure is expected to model expected failures as events; a leaked
|
|
207
|
+
// defect (a bug in the body) is logged before being isolated, so one
|
|
208
|
+
// procedure's crash never tears down the channel fiber — but is observable.
|
|
209
|
+
const runMatching = (event: Tagged): Effect.Effect<void> =>
|
|
210
|
+
Effect.forEach(
|
|
211
|
+
procedures.byChannelTag.get(name)?.get(event._tag) ?? [],
|
|
212
|
+
(e) =>
|
|
213
|
+
e.run(event).pipe(
|
|
214
|
+
Effect.tapErrorCause((cause) =>
|
|
215
|
+
Effect.logError(`reform: procedure on channel '${name}' failed`, cause),
|
|
216
|
+
),
|
|
217
|
+
Effect.catchAllCause(() => Effect.void),
|
|
218
|
+
),
|
|
219
|
+
{ discard: true, concurrency: 'unbounded' },
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
// Map the policy to its scheduling stream. `Match.exhaustive` makes a new
|
|
223
|
+
// policy variant a compile error, and each arm is a self-contained const.
|
|
224
|
+
const events = Stream.fromQueue(queue)
|
|
225
|
+
const driven: Stream.Stream<unknown> = Match.value(channel.policy).pipe(
|
|
226
|
+
Match.tag('merge', () => Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' })),
|
|
227
|
+
Match.tag('latest', () =>
|
|
228
|
+
Stream.flatMap(events, (e) => Stream.fromEffect(runMatching(e)), { switch: true }),
|
|
229
|
+
),
|
|
230
|
+
Match.tag('debounce', (policy) =>
|
|
231
|
+
events.pipe(
|
|
232
|
+
Stream.debounce(policy.duration),
|
|
233
|
+
Stream.mapEffect(runMatching, { concurrency: 'unbounded' }),
|
|
234
|
+
),
|
|
235
|
+
),
|
|
236
|
+
Match.tag('throttle', (policy) =>
|
|
237
|
+
events.pipe(
|
|
238
|
+
Stream.throttle({
|
|
239
|
+
cost: (chunk) => Chunk.size(chunk) * (policy.cost ?? 1),
|
|
240
|
+
units: policy.units,
|
|
241
|
+
duration: policy.duration,
|
|
242
|
+
...(policy.burst !== undefined ? { burst: policy.burst } : {}),
|
|
243
|
+
strategy: policy.strategy ?? 'shape',
|
|
244
|
+
}),
|
|
245
|
+
Stream.mapEffect(runMatching, { concurrency: 'unbounded' }),
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
// `concurrency: 1` runs one event at a time AND in offer order — a true
|
|
249
|
+
// serialized lane. (An unbounded fork racing for a single semaphore permit
|
|
250
|
+
// would serialize but could reorder under contention.)
|
|
251
|
+
Match.tag('exclusive', () => Stream.mapEffect(events, runMatching, { concurrency: 1 })),
|
|
252
|
+
Match.exhaustive,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
yield* Effect.forkScoped(Stream.runDrain(driven))
|
|
256
|
+
channels.byName.set(name, {
|
|
257
|
+
name,
|
|
258
|
+
policy: channel.policy,
|
|
259
|
+
offer: (event) => {
|
|
260
|
+
Queue.unsafeOffer(queue, event)
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
// On scope close (a lazy feature unmounting) remove the live channel so the
|
|
264
|
+
// loop stops routing to it; the drain fiber is already torn down by
|
|
265
|
+
// `forkScoped`. Eager channels live on the root scope, so this is a no-op
|
|
266
|
+
// until app dispose.
|
|
267
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => channels.byName.delete(name)))
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Context, Effect, Layer, type Schema } from 'effect'
|
|
2
|
+
import type { YieldWrap } from 'effect/Utils'
|
|
3
|
+
import { type Manifest, definitionClass } from '../definition/definition'
|
|
4
|
+
import { type Ctx } from '../internal/ctx'
|
|
5
|
+
import type { Tracker } from '../internal/track'
|
|
6
|
+
import { CurrentTracker } from '../internal/track'
|
|
7
|
+
import type { SlotClass } from './slot'
|
|
8
|
+
import type { Structure } from './structure'
|
|
9
|
+
import type { UiClass, UiContract } from './ui'
|
|
10
|
+
import { Props } from './props'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a composition's logic returns for one frame: a serializable {@link Structure}
|
|
14
|
+
* value — its computed props, per-slot fills, and event triggers, all as DATA. A body
|
|
15
|
+
* never returns (nor evaluates) a React node, so the engine, the wire server, and the
|
|
16
|
+
* proof harness consume the frame without React; presentation resolves the contract's
|
|
17
|
+
* view separately, by name, on a host.
|
|
18
|
+
*/
|
|
19
|
+
export type Frame<C extends UiContract = UiContract> = Structure<C>
|
|
20
|
+
|
|
21
|
+
/** Everything the host injects per render: the instance props and the dependency tracker. */
|
|
22
|
+
export interface RenderEnv {
|
|
23
|
+
readonly props: unknown
|
|
24
|
+
readonly tracker: Tracker
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A mounted composition: given a render environment, produces the current frame —
|
|
28
|
+
* a {@link Structure} value (see `Frame`). */
|
|
29
|
+
export interface CompositionService {
|
|
30
|
+
readonly render: (env: RenderEnv) => Effect.Effect<Frame, never, never>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CompositionConfig {
|
|
34
|
+
readonly title: string
|
|
35
|
+
readonly description?: string
|
|
36
|
+
// `any` in the encoded/context slots is required by Schema's variance for
|
|
37
|
+
// branded/Class schemas (e.g. `Todo`); only the decoded type `P` is used.
|
|
38
|
+
readonly props?: Schema.Schema<any, any>
|
|
39
|
+
readonly states?: ReadonlyArray<unknown>
|
|
40
|
+
readonly calcs?: ReadonlyArray<unknown>
|
|
41
|
+
readonly events?: ReadonlyArray<unknown>
|
|
42
|
+
readonly slots?: Record<string, SlotClass>
|
|
43
|
+
// The UI contract this composition resolves. Typed as a manifest carrier (not
|
|
44
|
+
// the full `UiClass`, whose invariant `impl` tag would force the contract type
|
|
45
|
+
// to leak here) so reflection — the dev tool, proofs — can read `ui.manifest`.
|
|
46
|
+
readonly ui: { readonly manifest: Manifest & { readonly kind: 'Ui' } }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type PropsOf<Config> = Config extends { props: Schema.Schema<infer P, any> } ? P : unknown
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The composition's state-group tuple, recovered from the `const`-inferred config
|
|
53
|
+
* so consumers can type its seeds. Erased to `readonly []` when no `states` are
|
|
54
|
+
* declared; entries that are not state groups contribute no seed keys (`SeedsOf`).
|
|
55
|
+
*/
|
|
56
|
+
type StatesOf<Config> = Config extends { states: infer S extends ReadonlyArray<unknown> }
|
|
57
|
+
? S
|
|
58
|
+
: readonly []
|
|
59
|
+
|
|
60
|
+
export interface CompositionClass<
|
|
61
|
+
out P,
|
|
62
|
+
out C extends UiContract = UiContract,
|
|
63
|
+
out S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
|
|
64
|
+
> {
|
|
65
|
+
new (): {}
|
|
66
|
+
readonly manifest: Manifest & { readonly kind: 'Composition' } & CompositionConfig
|
|
67
|
+
/** Phantom carrying the props type for `provide(slot, composition)` checks. */
|
|
68
|
+
readonly Props: P
|
|
69
|
+
/**
|
|
70
|
+
* Phantom carrying the resolved UI contract (props + events + slots). It lets
|
|
71
|
+
* reflection consumers — proofs, the dev tool — recover the full contract from
|
|
72
|
+
* a composition value and stay fully typed. Covariant (output only); the
|
|
73
|
+
* invariant `impl` tag is never stored here, so no variance regression.
|
|
74
|
+
*/
|
|
75
|
+
readonly Contract: C
|
|
76
|
+
/**
|
|
77
|
+
* Phantom carrying the state-group tuple, so `seedScene` can type a scene's
|
|
78
|
+
* seeds against the actual state members. Same erasure trick as `Contract`.
|
|
79
|
+
*/
|
|
80
|
+
readonly States: S
|
|
81
|
+
readonly tag: Context.Tag<CompositionService, CompositionService>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A component's logic. The *definition* (`Composition.make`) is a reflectable
|
|
86
|
+
* manifest that doubles as a requirement/tag; the *implementation*
|
|
87
|
+
* (`Composition.live`) is the synchronous body, provided separately and
|
|
88
|
+
* checked against it.
|
|
89
|
+
*/
|
|
90
|
+
export const make = <const Config extends CompositionConfig, C extends UiContract>(
|
|
91
|
+
name: string,
|
|
92
|
+
// Constrain `ui` to the full `UiClass<C>` so `C` is inferred here; the stored
|
|
93
|
+
// manifest still sees only the erased `{ manifest }` carrier (CompositionConfig),
|
|
94
|
+
// so the manifest type — and its variance — is unchanged.
|
|
95
|
+
config: Config & { readonly ui: UiClass<C> },
|
|
96
|
+
): CompositionClass<PropsOf<Config>, C, StatesOf<Config>> => {
|
|
97
|
+
const tag = Context.GenericTag<CompositionService, CompositionService>(
|
|
98
|
+
`reform/composition/${name}`,
|
|
99
|
+
)
|
|
100
|
+
const manifest = { kind: 'Composition' as const, name, ...config }
|
|
101
|
+
return definitionClass<CompositionClass<PropsOf<Config>, C, StatesOf<Config>>>({ manifest, tag })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Wire a composition's synchronous logic (D2): read state, acquire triggers,
|
|
106
|
+
* resolve the UI, return the view. Its requirements (minus `Props`, which the
|
|
107
|
+
* host injects per render) surface as the Layer's `RIn`; the runtime runs the
|
|
108
|
+
* body per render within the captured context.
|
|
109
|
+
*/
|
|
110
|
+
export const live = <
|
|
111
|
+
P,
|
|
112
|
+
C extends UiContract,
|
|
113
|
+
Eff extends YieldWrap<Effect.Effect<unknown, never, unknown>>,
|
|
114
|
+
>(
|
|
115
|
+
// The contract `C` is captured (not erased to `any`) so the body's returned
|
|
116
|
+
// `Structure` is checked against the composition's contract.
|
|
117
|
+
composition: CompositionClass<P, C>,
|
|
118
|
+
body: () => Generator<Eff, Frame<C>, never>,
|
|
119
|
+
): Layer.Layer<CompositionService, never, Exclude<Ctx<Eff>, Props>> => {
|
|
120
|
+
const logic = Effect.gen(body)
|
|
121
|
+
return Layer.effect(
|
|
122
|
+
composition.tag,
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
// Capture the build-time context (state stores, calc, ui, bus). `Props` and
|
|
125
|
+
// the tracker are injected per render below.
|
|
126
|
+
const context = yield* Effect.context<Exclude<Ctx<Eff>, Props>>()
|
|
127
|
+
const render = (env: RenderEnv): Effect.Effect<Frame, never, never> =>
|
|
128
|
+
// The per-render erasure seam: the body is total once fully provided (it is
|
|
129
|
+
// synchronous, reads can't fail, every requirement is now satisfied — D2) and
|
|
130
|
+
// its `Structure<C>` erases to the service's contract-agnostic `Frame`. `gen`
|
|
131
|
+
// widens the error channel to the body's inferred `E` for a generic body, so
|
|
132
|
+
// narrowing it (and the contract) back to the erased service type needs the
|
|
133
|
+
// `unknown` bridge — the one cast this render boundary has always required.
|
|
134
|
+
logic.pipe(
|
|
135
|
+
Effect.provideService(Props, env.props),
|
|
136
|
+
Effect.provideService(CurrentTracker, env.tracker),
|
|
137
|
+
Effect.provide(context),
|
|
138
|
+
) as unknown as Effect.Effect<Frame, never, never>
|
|
139
|
+
return { render }
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Run a mounted composition for one frame, producing its `Frame` (a `Structure`). */
|
|
145
|
+
export const render = (service: CompositionService, env: RenderEnv): Effect.Effect<Frame, never, never> =>
|
|
146
|
+
service.render(env)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Node } from './slot'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A slot handle the host hands a presentation: BOTH renderable (call it / use as a
|
|
5
|
+
* component to place the keyed children) AND inspectable (`.props` — the array of
|
|
6
|
+
* per-child fill props the structure carried, one entry per mounted child). The
|
|
7
|
+
* structure host (`renderStructure`) populates `.props` from the fills. Plan 06.
|
|
8
|
+
*/
|
|
9
|
+
export interface SlotRenderer {
|
|
10
|
+
(props: unknown): Node
|
|
11
|
+
readonly props: ReadonlyArray<unknown>
|
|
12
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Context } from 'effect'
|
|
1
|
+
import { Context } from 'effect'
|
|
2
|
+
|
|
2
3
|
/**
|
|
3
4
|
* The current composition instance's props. Bound per render by the runtime; a
|
|
4
5
|
* composition body reads them with `const { todo } = yield* Props`.
|
|
@@ -6,7 +7,8 @@ import { Context } from 'effect';
|
|
|
6
7
|
* (Per-instance typing of Props is DESIGN #26 — for now the value is opaque and
|
|
7
8
|
* narrowed at the use site against the composition's declared props schema.)
|
|
8
9
|
*/
|
|
9
|
-
const PropsBase = Context.Tag('reform/Props')
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const PropsBase: Context.TagClass<Props, 'reform/Props', any> = Context.Tag('reform/Props')<
|
|
11
|
+
Props,
|
|
12
|
+
any
|
|
13
|
+
>()
|
|
14
|
+
export class Props extends PropsBase {}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type Context, Layer } from 'effect'
|
|
2
|
+
import { InvalidProvideTarget } from '../internal/errors'
|
|
3
|
+
import type { CompositionClass } from './composition'
|
|
4
|
+
import {
|
|
5
|
+
type EngineServices,
|
|
6
|
+
type FeatureClass,
|
|
7
|
+
isFeature,
|
|
8
|
+
type ProvidedBy,
|
|
9
|
+
type RequireCarrier,
|
|
10
|
+
} from '../feature/feature'
|
|
11
|
+
import { isSlot, type SlotChild, type SlotClass } from './slot'
|
|
12
|
+
import { isUi, type UiClass, type UiContract, type ViewImpl } from './ui'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wire an implementation to a contract. Provides a `.make` presentation to a UI
|
|
16
|
+
* contract, a child composition into a parent's slot, or a `Feature` into a slot.
|
|
17
|
+
* The compose code never imports its implementations — they meet here.
|
|
18
|
+
*/
|
|
19
|
+
export function provide<C extends UiContract>(
|
|
20
|
+
contract: UiClass<C>,
|
|
21
|
+
view: ViewImpl<C>,
|
|
22
|
+
): Layer.Layer<ViewImpl<C>>
|
|
23
|
+
export function provide<Comp extends CompositionClass<any, any>>(
|
|
24
|
+
hole: SlotClass<Comp>,
|
|
25
|
+
composition: Comp,
|
|
26
|
+
): Layer.Layer<CompositionClass<unknown>>
|
|
27
|
+
/**
|
|
28
|
+
* Fill a slot with a feature. The slot's child composition type
|
|
29
|
+
* (`CompositionClass<P, C>`) must match the feature's mounted composition, so the
|
|
30
|
+
* facade/contract stays typed. The returned layer surfaces the feature's shared
|
|
31
|
+
* requirement (`EngineServices | ProvidedBy<Requires>`) as an open `RIn`, so the
|
|
32
|
+
* scene that wires it won't compile unless it provides the matching `Core` — even
|
|
33
|
+
* for a lazy feature whose own `.live` layer isn't built until mount.
|
|
34
|
+
*/
|
|
35
|
+
export function provide<
|
|
36
|
+
Comp extends CompositionClass<any, any>,
|
|
37
|
+
ROut,
|
|
38
|
+
Requires extends ReadonlyArray<RequireCarrier>,
|
|
39
|
+
>(
|
|
40
|
+
hole: SlotClass<Comp>,
|
|
41
|
+
feature: FeatureClass<
|
|
42
|
+
Comp extends CompositionClass<infer P, any> ? P : never,
|
|
43
|
+
Comp extends CompositionClass<any, infer C> ? C : never,
|
|
44
|
+
ROut,
|
|
45
|
+
Requires
|
|
46
|
+
>,
|
|
47
|
+
): Layer.Layer<SlotChild, never, EngineServices | ProvidedBy<Requires>>
|
|
48
|
+
export function provide(target: unknown, impl: unknown): Layer.Layer<never, never, unknown> {
|
|
49
|
+
// Discriminate the target by its nominal brand, not by which DI-hole property
|
|
50
|
+
// happens to exist: a UI contract provides under its `impl` tag, a slot under
|
|
51
|
+
// its `tag`. Anything else is a wiring mistake. The overloads above pair
|
|
52
|
+
// `impl` with the chosen tag; this implementation sees both erased, so view
|
|
53
|
+
// the tag loosely (the one documented cast) to provide the service under it.
|
|
54
|
+
const tag = (isUi(target) ? target.impl : isSlot(target) ? target.tag : undefined) as
|
|
55
|
+
| Context.Tag<unknown, unknown>
|
|
56
|
+
| undefined
|
|
57
|
+
if (tag === undefined) throw new InvalidProvideTarget()
|
|
58
|
+
if (isFeature(impl)) {
|
|
59
|
+
const eager = impl.eagerModule
|
|
60
|
+
// Lazy: bind the `FeatureBinding`; the host drives load → mount, painting the
|
|
61
|
+
// placeholders. Default: bind the composition (so the existing `Compose` path
|
|
62
|
+
// renders it, no host, no flash) and merge the eager `.live` layer, whose `RIn`
|
|
63
|
+
// surfaces to the scene. Either way the overload's return type carries the
|
|
64
|
+
// shared `RIn`, so the scene still type-demands `Core`.
|
|
65
|
+
return eager === undefined
|
|
66
|
+
? Layer.succeed(tag, impl.binding)
|
|
67
|
+
: Layer.merge(Layer.succeed(tag, impl.binding.composition), eager.layer)
|
|
68
|
+
}
|
|
69
|
+
return Layer.succeed(tag, impl)
|
|
70
|
+
}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
import { Context } from 'effect'
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
5
|
-
|
|
1
|
+
import { Context } from 'effect'
|
|
2
|
+
import { definitionClass } from '../definition/definition'
|
|
3
|
+
import type { CompositionClass } from './composition'
|
|
4
|
+
import type { FeatureBinding } from '../feature/feature'
|
|
5
|
+
import type { Node } from '../ui/node'
|
|
6
|
+
|
|
7
|
+
export type { Node }
|
|
8
|
+
|
|
6
9
|
/** Any composition, used where only the slot's structural role matters. */
|
|
7
|
-
type AnyComposition = CompositionClass<any, any
|
|
10
|
+
type AnyComposition = CompositionClass<any, any>
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
13
|
* What a slot can be filled with: a plain child composition (the host renders it
|
|
10
14
|
* directly), or a `FeatureBinding` from `provide(slot, Feature)` (the host drives
|
|
11
15
|
* its lazy/eager mount lifecycle). The slot's DI tag holds one of these; the host
|
|
12
16
|
* discriminates by the `FeatureBinding` brand.
|
|
13
17
|
*/
|
|
14
|
-
export type SlotChild = CompositionClass<unknown> | FeatureBinding
|
|
18
|
+
export type SlotChild = CompositionClass<unknown> | FeatureBinding
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
21
|
* Phantom instance shape: `ItemSlot` used as a *type* denotes this. It carries
|
|
17
22
|
* the child *composition* type `Comp`, so the slot knows both the external props
|
|
@@ -20,8 +25,9 @@ export type SlotChild = CompositionClass<unknown> | FeatureBinding;
|
|
|
20
25
|
* proof facade, drive.
|
|
21
26
|
*/
|
|
22
27
|
export interface SlotInstance<Comp extends AnyComposition> {
|
|
23
|
-
|
|
28
|
+
readonly _slot: Comp
|
|
24
29
|
}
|
|
30
|
+
|
|
25
31
|
/**
|
|
26
32
|
* A DI hole for a child composition. The parent declares
|
|
27
33
|
* `slot('Item')<typeof TodoItem>()` and renders through it; the concrete child
|
|
@@ -32,18 +38,29 @@ export interface SlotInstance<Comp extends AnyComposition> {
|
|
|
32
38
|
* by `provide`'s signature, not the (invariant) tag.
|
|
33
39
|
*/
|
|
34
40
|
/** Nominal brand identifying a slot at runtime (Effect's `TypeId` idiom). */
|
|
35
|
-
export
|
|
36
|
-
export type SlotTypeId = typeof SlotTypeId
|
|
41
|
+
export const SlotTypeId: unique symbol = Symbol.for('reform/Slot')
|
|
42
|
+
export type SlotTypeId = typeof SlotTypeId
|
|
43
|
+
|
|
37
44
|
export interface SlotClass<Comp extends AnyComposition = AnyComposition> {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
new (): SlotInstance<Comp>
|
|
46
|
+
readonly [SlotTypeId]: SlotTypeId
|
|
47
|
+
readonly kind: 'Slot'
|
|
48
|
+
/** Internal DI tag the filling child (composition or feature binding) is provided under. */
|
|
49
|
+
readonly tag: Context.Tag<SlotChild, SlotChild>
|
|
43
50
|
}
|
|
51
|
+
|
|
44
52
|
/** Whether a value is a slot — discriminates a `provide` target by brand. */
|
|
45
|
-
export
|
|
53
|
+
export const isSlot = (u: unknown): u is SlotClass =>
|
|
54
|
+
(typeof u === 'function' || typeof u === 'object') && u !== null && SlotTypeId in u
|
|
55
|
+
|
|
46
56
|
/** The props a slot renders with — the child composition's external props. */
|
|
47
|
-
export type SlotProps<S> = S extends SlotInstance<infer Comp> ? Comp['Props'] : never
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
export type SlotProps<S> = S extends SlotInstance<infer Comp> ? Comp['Props'] : never
|
|
58
|
+
|
|
59
|
+
export const slot =
|
|
60
|
+
(name: string) =>
|
|
61
|
+
// Defaulted so a slot whose child carries no meaningful props/contract can be
|
|
62
|
+
// declared bare (`slot('Tree')()`); pass `typeof Child` to type the facade.
|
|
63
|
+
<Comp extends AnyComposition = AnyComposition>(): SlotClass<Comp> => {
|
|
64
|
+
const tag = Context.GenericTag<SlotChild, SlotChild>(`reform/slot/${name}`)
|
|
65
|
+
return definitionClass<SlotClass<Comp>>({ [SlotTypeId]: SlotTypeId, kind: 'Slot' as const, tag })
|
|
66
|
+
}
|