@playfast/reform-remote 1.0.2 → 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.
Files changed (54) hide show
  1. package/README.md +16 -16
  2. package/dist/client.d.ts +33 -0
  3. package/dist/client.d.ts.map +1 -0
  4. package/dist/client.js +80 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/index.d.ts +11 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +11 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/memory.d.ts +7 -0
  11. package/dist/memory.d.ts.map +1 -0
  12. package/dist/memory.js +21 -0
  13. package/dist/memory.js.map +1 -0
  14. package/dist/react.d.ts +15 -0
  15. package/dist/react.d.ts.map +1 -0
  16. package/dist/react.js +11 -0
  17. package/dist/react.js.map +1 -0
  18. package/dist/server.d.ts +16 -0
  19. package/dist/server.d.ts.map +1 -0
  20. package/dist/server.js +56 -0
  21. package/dist/server.js.map +1 -0
  22. package/dist/transport.d.ts +53 -0
  23. package/dist/transport.d.ts.map +1 -0
  24. package/dist/transport.js +171 -0
  25. package/dist/transport.js.map +1 -0
  26. package/dist/wireNode.d.ts +24 -0
  27. package/dist/wireNode.d.ts.map +1 -0
  28. package/dist/wireNode.js +80 -0
  29. package/dist/wireNode.js.map +1 -0
  30. package/dist/wireRender.d.ts +20 -0
  31. package/dist/wireRender.d.ts.map +1 -0
  32. package/dist/wireRender.js +97 -0
  33. package/dist/wireRender.js.map +1 -0
  34. package/package.json +34 -22
  35. package/src/client-keyed-slot.test.ts +19 -6
  36. package/src/client-remount.test.ts +14 -5
  37. package/src/client.ts +74 -41
  38. package/src/counterFixtures.ts +142 -0
  39. package/src/fixtures.ts +10 -247
  40. package/src/listFixtures.ts +174 -0
  41. package/src/optionalFixtures.ts +64 -0
  42. package/src/react.ts +1 -6
  43. package/src/remote-view.typecheck.ts +3 -2
  44. package/src/server.test.ts +26 -12
  45. package/src/server.ts +150 -211
  46. package/src/slottedFixtures.ts +120 -0
  47. package/src/transport.test.ts +2 -1
  48. package/src/transport.ts +88 -24
  49. package/src/wireNode.ts +153 -0
  50. package/src/wireRender.ts +235 -0
  51. package/src/connect.ts +0 -44
  52. package/src/contract.ts +0 -3
  53. package/src/remote-server.ts +0 -109
  54. package/src/transport.types.ts +0 -29
@@ -2,8 +2,14 @@ import { createElement, Fragment, type ReactNode } from 'react'
2
2
  import { renderToStaticMarkup } from 'react-dom/server'
3
3
  import { expect, test } from 'vitest'
4
4
  import { Option } from 'effect'
5
- import { Ui, Wire } from '@playfast/reform'
6
- import type { WireNode, WirePatch, WireProp, WireTree } from '@playfast/reform'
5
+ import { Ui } from '@playfast/reform'
6
+ import {
7
+ Wire,
8
+ type WireNode,
9
+ type WirePatch,
10
+ type WireProp,
11
+ type WireTree,
12
+ } from '@playfast/reform/internal'
7
13
  import { makeRemoteServer } from './server'
8
14
  import { remoteViews, renderWireTree } from './client'
9
15
  import {
@@ -17,15 +23,20 @@ import {
17
23
  structureCounterScene,
18
24
  } from './fixtures'
19
25
 
20
- const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
26
+ const draw = (node: ReactNode): void =>
27
+ void renderToStaticMarkup(createElement(Fragment, null, node))
21
28
 
22
29
  const dataOf = (node: WireNode, name: string): WireProp | undefined =>
23
30
  node.props.find((prop) => prop._tag === 'Data' && prop.name === name)
24
31
 
25
32
  const eventOf = (node: WireNode, name: string): Extract<WireProp, { _tag: 'Event' }> | undefined =>
26
- node.props.find((prop): prop is Extract<WireProp, { _tag: 'Event' }> => prop._tag === 'Event' && prop.name === name)
33
+ node.props.find(
34
+ (prop): prop is Extract<WireProp, { _tag: 'Event' }> =>
35
+ prop._tag === 'Event' && prop.name === name,
36
+ )
27
37
 
28
- const byId = (tree: WireTree, id: string): WireNode | undefined => tree.find((node) => node.id === id)
38
+ const byId = (tree: WireTree, id: string): WireNode | undefined =>
39
+ tree.find((node) => node.id === id)
29
40
 
30
41
  const upsertIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
31
42
  patches.flatMap((patch) => (patch._tag === 'Upsert' ? [patch.node.id] : []))
@@ -62,7 +73,6 @@ test('invoking a trigger handle dispatches into the runtime and the next render
62
73
  }
63
74
  })
64
75
 
65
-
66
76
  test('a mount(...)-returning body registers its event from structure.events and invoking it dispatches', async () => {
67
77
  const server = makeRemoteServer(structureCounterScene())
68
78
  try {
@@ -110,7 +120,10 @@ test('renderDiff streams patches a client folds back to the current tree', async
110
120
 
111
121
  test('end to end: client reconstructs props + event callbacks that drive the server', async () => {
112
122
  const server = makeRemoteServer(counterScene())
113
- const captured: { props?: Record<string, unknown>; events?: Record<string, (p: { by: number }) => void> } = {}
123
+ const captured: {
124
+ props?: Record<string, unknown>
125
+ events?: Record<string, (p: { by: number }) => void>
126
+ } = {}
114
127
  const sent: Array<[string, unknown]> = []
115
128
  try {
116
129
  const tree = await server.render()
@@ -149,7 +162,6 @@ test('boot events are applied before the first render', async () => {
149
162
  }
150
163
  })
151
164
 
152
-
153
165
  test('a slotted child renders nested with its own encoded props and handle', async () => {
154
166
  const server = makeRemoteServer(slottedScene())
155
167
  try {
@@ -197,16 +209,19 @@ test('two events on one node register distinct handles and each fires independen
197
209
  expect(eventOf(before, 'clear')?.handle).toBe('0.Main.0:clear')
198
210
 
199
211
  await server.invoke('0.Main.0:greet', { text: 'hello' })
200
- expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({ value: 'hello' })
212
+ expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({
213
+ value: 'hello',
214
+ })
201
215
 
202
216
  await server.invoke('0.Main.0:clear', {})
203
- expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({ value: '' })
217
+ expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({
218
+ value: '',
219
+ })
204
220
  } finally {
205
221
  await server.dispose()
206
222
  }
207
223
  })
208
224
 
209
-
210
225
  test('a list renders one child per item under a single slot, in order', async () => {
211
226
  const server = makeRemoteServer(listScene())
212
227
  try {
@@ -255,7 +270,6 @@ test("a removed node's handle is revoked — a stale client invocation rejects",
255
270
  }
256
271
  })
257
272
 
258
-
259
273
  test('an Option prop round-trips through JSON as a real Option (symmetric schema decode)', async () => {
260
274
  const server = makeRemoteServer(optionScene(Option.some('hi')))
261
275
  const captured: { label?: unknown } = {}
package/src/server.ts CHANGED
@@ -1,241 +1,180 @@
1
1
  import {
2
- Array as Arr,
3
2
  Effect,
4
- Match,
3
+ Layer,
4
+ ManagedRuntime,
5
5
  Option,
6
6
  type ParseResult,
7
- Record as Rec,
8
- Schema,
7
+ PubSub,
8
+ Queue,
9
9
  } from 'effect'
10
+ import { type CapturedScene, forceSync, publish, type UiContract } from '@playfast/reform'
10
11
  import {
11
- Composition,
12
- type CompositionClass,
13
- type CompositionService,
14
- isFeatureBinding,
15
- isStructure,
16
- type RenderEnv,
17
- type Scene,
18
- type SlotChild,
19
- type SlotClass,
20
- type SlotFill,
21
- type Structure,
22
- type Trigger,
12
+ type AnyScene,
13
+ Bus,
14
+ Triggers,
23
15
  type TriggerRegistryApi,
24
- type UiContract,
25
- type UiManifest,
26
- type WireNode,
27
- type WireProp,
16
+ Wire,
17
+ type WirePatch,
28
18
  type WireTree,
29
- } from '@playfast/reform'
30
-
31
- // At erased UiContract boundary, event map is Record<never, never> → assignable to Trigger<unknown> without cast.
32
- const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
33
- Option.match(Option.fromNullable(structure.events), {
34
- onNone: () => ({}),
35
- onSome: (events) => events,
36
- })
37
-
38
- interface Mounted {
39
- readonly comp: CompositionClass<unknown>
40
- readonly props: unknown
41
- readonly id: string
42
- readonly parentId: Option.Option<string>
43
- readonly slot: Option.Option<string>
44
- readonly childIndex: number
45
- readonly key: Option.Option<string>
46
- }
19
+ } from '@playfast/reform/internal'
20
+ import {
21
+ capturedSlotProvider,
22
+ renderCapturedComposition,
23
+ type RenderSceneToWireOptions,
24
+ renderSceneWith,
25
+ serviceFromContext,
26
+ } from './wireRender'
27
+ import { handlesOf, settleDrain } from './wireNode'
47
28
 
48
- const childComposition = (child: SlotChild): CompositionClass<unknown> =>
49
- isFeatureBinding(child) ? child.composition : child
29
+ export type { RenderSceneToWireOptions } from './wireRender'
50
30
 
51
- // One-shot render (screenshot): wire still has Event handles; registrar is no-op.
52
- const noRegister: TriggerRegistryApi['register'] = () => Effect.void
31
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- exported binding: Effect.fn's inferred type isn't portable under isolatedDeclarations
32
+ export const renderSceneToWire = <
33
+ C extends UiContract,
34
+ S extends ReadonlyArray<unknown>,
35
+ Services,
36
+ P,
37
+ N extends string,
38
+ Identity,
39
+ >(
40
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
41
+ options?: RenderSceneToWireOptions,
42
+ ): Effect.Effect<WireTree, ParseResult.ParseError, Services> =>
43
+ Effect.context<Services>().pipe(
44
+ Effect.flatMap((context) =>
45
+ renderSceneWith(
46
+ scene,
47
+ options,
48
+ (slotDefinition) => capturedSlotProvider(context, slotDefinition),
49
+ (composition, props) => renderCapturedComposition(context, composition, props),
50
+ ),
51
+ ),
52
+ )
53
53
 
54
- const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
55
- mounted: Mounted,
56
- structure: Structure<UiContract>,
57
- register: TriggerRegistryApi['register'],
58
- ): Effect.fn.Return<WireNode, ParseResult.ParseError> {
59
- const manifest: UiManifest = mounted.comp.manifest.ui.manifest
60
- const name = manifest.name
54
+ export interface RemoteServer {
55
+ readonly render: () => Promise<WireTree>
56
+ readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
57
+ readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
58
+ readonly subscribe: (listener: () => void) => () => void
59
+ readonly currentTree: () => WireTree
60
+ readonly dispose: () => Promise<void>
61
+ }
61
62
 
62
- const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
63
- onNone: () => Effect.succeed({}),
64
- onSome: (schema) => Schema.encodeUnknown(schema)(structure.props),
65
- })
66
- const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
67
- ([propName, propValue]): WireProp => ({ _tag: 'Data', name: propName, value: propValue }),
63
+ const makeCapturedRemoteServer = <
64
+ C extends UiContract,
65
+ S extends ReadonlyArray<unknown>,
66
+ Services,
67
+ P,
68
+ N extends string,
69
+ Identity,
70
+ >(
71
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
72
+ ): RemoteServer => {
73
+ const layer = scene.provide.reduce((acc, provided) => Layer.merge(acc, provided))
74
+ const runtime = ManagedRuntime.make(layer)
75
+ const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
76
+ const context = forceSync(() => runtime.runSync(Effect.context<Services>()))
77
+ const busFromContext = serviceFromContext(
78
+ context,
79
+ Bus,
80
+ 'reform-remote: scene does not provide Bus',
68
81
  )
69
82
 
70
- const eventSchemas = Option.fromNullable(manifest.events)
71
- const events = eventsOf(structure)
72
- const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(Rec.toEntries(events), ([eventName, trigger]) =>
73
- Effect.gen(function* () {
74
- const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
75
- if (Option.isNone(schema)) {
76
- return Option.none<WireProp>()
77
- }
78
- const handle = `${mounted.id}:${eventName}`
79
- yield* register(handle, trigger, schema.value)
80
- return Option.some<WireProp>({ _tag: 'Event', name: eventName, handle })
81
- }),
82
- ).pipe(Effect.map(Arr.getSomes))
83
+ const renderEffect = settleDrain.pipe(
84
+ Effect.zipRight(
85
+ renderSceneWith(
86
+ scene,
87
+ { register: registry.register },
88
+ (slotDefinition) => capturedSlotProvider(context, slotDefinition),
89
+ (composition, props) => renderCapturedComposition(context, composition, props),
90
+ ),
91
+ ),
92
+ )
83
93
 
84
- return {
85
- id: mounted.id,
86
- name,
87
- parentId: Option.getOrNull(mounted.parentId),
88
- childIndex: mounted.childIndex,
89
- slot: Option.getOrNull(mounted.slot),
90
- key: Option.getOrNull(mounted.key),
91
- props: [...dataProps, ...eventProps],
94
+ const changeListeners = new Set<() => void>()
95
+ const notifyChange = (): void => {
96
+ changeListeners.forEach((listener) => listener())
92
97
  }
93
- })
94
-
95
- // Structure erases per-slot fill types; recover SlotFill structurally without `as`.
96
- const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
97
- typeof candidate === 'object' &&
98
- candidate !== null &&
99
- '_tag' in candidate &&
100
- (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
98
+ runtime.runFork(
99
+ Effect.scoped(
100
+ Effect.gen(function* () {
101
+ const bus = yield* busFromContext
102
+ const subscription = yield* PubSub.subscribe(bus)
103
+ // The transport debounces; settling here would perturb the drain's scheduling.
104
+ yield* Queue.take(subscription).pipe(
105
+ Effect.zipRight(Effect.sync(notifyChange)),
106
+ Effect.forever,
107
+ )
108
+ }),
109
+ ),
110
+ )
101
111
 
102
- const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
103
- Rec.fromEntries(
104
- Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
105
- isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
112
+ runtime.runSync(
113
+ Effect.forEach(
114
+ Option.getOrElse(Option.fromNullable(scene.boot), () => []),
115
+ (event) =>
116
+ busFromContext.pipe(
117
+ Effect.flatMap((bus) => publish('High', event).pipe(Effect.provideService(Bus, bus))),
118
+ ),
106
119
  ),
107
120
  )
108
121
 
109
- const slotsOf = (comp: CompositionClass<unknown>): Record<string, SlotClass> =>
110
- Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
122
+ const frame: { tree: WireTree } = { tree: [] }
111
123
 
112
- const enqueueStructureSlot = (
113
- parent: Mounted,
114
- slotName: string,
115
- child: CompositionClass<unknown>,
116
- fill: SlotFill<unknown>,
117
- ): ReadonlyArray<Mounted> =>
118
- Match.value(fill).pipe(
119
- Match.tag('Each', (each) =>
120
- each.items.map(
121
- (entry, index): Mounted => ({
122
- comp: child,
123
- props: entry.props,
124
- id: `${parent.id}.${slotName}.${index}`,
125
- parentId: Option.some(parent.id),
126
- slot: Option.some(slotName),
127
- childIndex: index,
128
- key: Option.fromNullable(entry.key),
129
- }),
124
+ const render = (): Promise<WireTree> =>
125
+ runtime.runPromise(
126
+ renderEffect.pipe(
127
+ Effect.tap((tree) =>
128
+ Effect.sync(() => {
129
+ frame.tree = tree
130
+ }),
131
+ ),
130
132
  ),
131
- ),
132
- Match.tag('One', (single) => [
133
- {
134
- comp: child,
135
- props: single.props,
136
- id: `${parent.id}.${slotName}.0`,
137
- parentId: Option.some(parent.id),
138
- slot: Option.some(slotName),
139
- childIndex: 0,
140
- key: Option.none(),
141
- } satisfies Mounted,
142
- ]),
143
- Match.tag('Absent', () => []),
144
- Match.exhaustive,
145
- )
133
+ )
146
134
 
147
- const collect = Effect.fn('collect')(function* (
148
- root: CompositionClass<unknown>,
149
- ): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
150
- const bindings = new Map<SlotClass, CompositionClass<unknown>>()
151
- // Explicit type so recursive walk references itself cast-free.
152
- const walk: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> = Effect.fn(
153
- 'walk',
154
- )(function* (comp: CompositionClass<unknown>): Effect.fn.Return<void, never, SlotChild> {
155
- yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
135
+ const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
136
+ runtime.runPromise(
156
137
  Effect.gen(function* () {
157
- if (bindings.has(slotClass)) {
158
- return
159
- }
160
- const child = childComposition(yield* slotClass.tag)
161
- bindings.set(slotClass, child)
162
- yield* walk(child)
138
+ const next = yield* renderEffect
139
+ const previous = frame.tree
140
+ const patches = Wire.diff(previous, next)
141
+ frame.tree = next
142
+ const nextIds = new Set(next.map((node) => node.id))
143
+ const staleHandles = previous.filter((node) => !nextIds.has(node.id)).flatMap(handlesOf)
144
+ yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
145
+ return patches
163
146
  }),
164
147
  )
165
- })
166
- yield* walk(root)
167
- return bindings
168
- })
169
148
 
170
- // Explicit type so recursive renderLevel references itself cast-free.
171
- const renderLevel: (
172
- frontier: ReadonlyArray<Mounted>,
173
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
174
- register: TriggerRegistryApi['register'],
175
- ) => Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> = Effect.fn(
176
- 'renderLevel',
177
- )(function* (
178
- frontier: ReadonlyArray<Mounted>,
179
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
180
- register: TriggerRegistryApi['register'],
181
- ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> {
182
- if (frontier.length === 0) {
183
- return []
149
+ return {
150
+ render,
151
+ renderDiff,
152
+ invoke: (handle, encodedPayload) =>
153
+ runtime.runPromise(
154
+ Effect.gen(function* () {
155
+ yield* registry.invoke(handle, encodedPayload)
156
+ yield* settleDrain
157
+ }),
158
+ ),
159
+ subscribe: (listener) => {
160
+ changeListeners.add(listener)
161
+ return () => void changeListeners.delete(listener)
162
+ },
163
+ currentTree: () => frame.tree,
164
+ dispose: () => runtime.dispose(),
184
165
  }
185
- const rendered = yield* Effect.forEach(frontier, (mounted) =>
186
- Effect.gen(function* () {
187
- const service = yield* mounted.comp.tag
188
- const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
189
- const frame = yield* Composition.render(service, env)
190
- if (!isStructure(frame)) {
191
- return yield* Effect.dieMessage(
192
- `reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
193
- )
194
- }
195
- const node = yield* toWireNodeFromStructure(mounted, frame, register)
196
- const fills = structureFills(frame)
197
- const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
198
- const child = bindings.get(slotClass)
199
- if (child === undefined) {
200
- return []
201
- }
202
- const fill = fills[slotName]
203
- if (fill === undefined) {
204
- return []
205
- }
206
- return enqueueStructureSlot(mounted, slotName, child, fill)
207
- })
208
- return { node, children }
209
- }),
210
- )
211
- const nodes = rendered.map((entry) => entry.node)
212
- const next = rendered.flatMap((entry) => entry.children)
213
- const rest = yield* renderLevel(next, bindings, register)
214
- return [...nodes, ...rest]
215
- })
216
-
217
- export interface RenderSceneToWireOptions {
218
- readonly register: TriggerRegistryApi['register']
219
166
  }
220
167
 
221
- // oxlint-disable-next-line reform-rules/prefer-effect-fn -- exported binding: Effect.fn's inferred type isn't portable under isolatedDeclarations
222
- export const renderSceneToWire = (
223
- scene: Scene,
224
- options?: RenderSceneToWireOptions,
225
- ): Effect.Effect<WireTree, ParseResult.ParseError, CompositionService | SlotChild> =>
226
- Effect.gen(function* () {
227
- const register = options?.register ?? noRegister
228
- const bindings = yield* collect(scene.composition)
229
- const root: Mounted = {
230
- comp: scene.composition,
231
- props: {},
232
- id: '0',
233
- parentId: Option.none(),
234
- slot: Option.none(),
235
- childIndex: 0,
236
- key: Option.none(),
237
- }
238
- return yield* renderLevel([root], bindings, register)
239
- })
240
-
241
- export { makeRemoteServer, type RemoteServer } from './remote-server'
168
+ export const makeRemoteServer = (scene: AnyScene): RemoteServer =>
169
+ scene.captureAny<RemoteServer>(
170
+ <
171
+ C extends UiContract,
172
+ S extends ReadonlyArray<unknown>,
173
+ Services,
174
+ P,
175
+ N extends string,
176
+ Identity,
177
+ >(
178
+ exactScene: CapturedScene<C, S, Services, P, N, Identity>,
179
+ ) => makeCapturedRemoteServer(exactScene),
180
+ )
@@ -0,0 +1,120 @@
1
+ import { createElement } from 'react'
2
+ import { Effect, Layer, Schema as S } from 'effect'
3
+ import {
4
+ Composition,
5
+ Engine,
6
+ Event,
7
+ Reducer,
8
+ State,
9
+ Ui,
10
+ mount,
11
+ one,
12
+ provide,
13
+ scene,
14
+ slot,
15
+ type CapturedScene,
16
+ type DerivedContract,
17
+ type SlotService,
18
+ ui,
19
+ } from '@playfast/reform'
20
+ import { type CompositionChildSummary, type EngineServices, type WiredUi } from '@playfast/reform/internal'
21
+
22
+ const GreetingBase: State.StateDefinition<'greeting', string, typeof S.String> = State.make(
23
+ 'greeting',
24
+ S.String,
25
+ )
26
+ class Greeting extends GreetingBase {}
27
+ class Greeted extends Event.make('Greeted', S.Struct({ text: S.String })) {}
28
+ class Cleared extends Event.make('Cleared', S.Struct({})) {}
29
+ class SetGreeting extends Reducer.make('SetGreeting', {
30
+ states: [Greeting],
31
+ events: [Greeted],
32
+ }) {}
33
+ class ClearGreeting extends Reducer.make('ClearGreeting', {
34
+ states: [Greeting],
35
+ events: [Cleared],
36
+ }) {}
37
+ const PanelUiBase: WiredUi<
38
+ DerivedContract<{
39
+ props: S.Struct<{ greeting: typeof S.String }>
40
+ events: {
41
+ greet: S.Struct<{ text: typeof S.String }>
42
+ clear: S.Struct<{}>
43
+ }
44
+ }>,
45
+ 'Panel'
46
+ > = ui('Panel', {
47
+ props: S.Struct({ greeting: S.String }),
48
+ events: { greet: S.Struct({ text: S.String }), clear: S.Struct({}) },
49
+ })
50
+ class PanelUi extends PanelUiBase {}
51
+
52
+ class Panel extends Composition.make('Panel', {
53
+ title: 'Panel',
54
+ ui: PanelUi,
55
+ states: [Greeting],
56
+ })<Panel>() {}
57
+
58
+ class MainSlot extends slot('Main')<MainSlot, typeof Panel>() {}
59
+ const ShellUiBase: Ui.UiClass<{ props: {}; slots: { Main: MainSlot } }, 'Shell'> = ui('Shell')<{
60
+ props: {}
61
+ slots: { Main: MainSlot }
62
+ }>()
63
+ class ShellUi extends ShellUiBase {}
64
+
65
+ class Shell extends Composition.make('Shell', {
66
+ title: 'Shell',
67
+ slots: { Main: MainSlot },
68
+ ui: ShellUi,
69
+ })<Shell>() {}
70
+
71
+ type SlottedScene = CapturedScene<
72
+ typeof ShellUi.Contract,
73
+ readonly [],
74
+ | EngineServices
75
+ | State.StateStore<'greeting', string>
76
+ | Ui.UiService<'Panel'>
77
+ | Ui.UiService<'Shell'>
78
+ | Composition.CompositionId<'Shell', Shell, MainSlot>
79
+ | Composition.CompositionId<'Panel', Panel, never>
80
+ | SlotService<'Main', MainSlot, CompositionChildSummary<Panel>>,
81
+ unknown,
82
+ 'Shell',
83
+ Shell
84
+ >
85
+
86
+ const makeSlottedScene = (): SlottedScene => {
87
+ const presentation = Layer.mergeAll(
88
+ provide(
89
+ ShellUi,
90
+ Ui.make(ShellUi, (_props, slots) => createElement(slots.Main, {})),
91
+ ),
92
+ provide(
93
+ PanelUi,
94
+ Ui.make(PanelUi, ({ greeting }) => greeting),
95
+ ),
96
+ provide(MainSlot, Panel),
97
+ State.live(Greeting, 'hi'),
98
+ )
99
+ const app = Layer.mergeAll(
100
+ Composition.live(Shell, function* () {
101
+ yield* Effect.void
102
+ return mount({ props: {}, slots: { Main: one({}) } })
103
+ }),
104
+ Composition.live(Panel, function* () {
105
+ const greeting = yield* Greeting
106
+ const greet = yield* Event.trigger(Greeted)
107
+ const clearTrigger = yield* Event.trigger(Cleared)
108
+ const clear = (): void => clearTrigger({})
109
+ return mount({
110
+ props: { greeting },
111
+ slots: {},
112
+ events: { greet, clear },
113
+ })
114
+ }),
115
+ Reducer.live(SetGreeting, (_greeting, event) => event.text),
116
+ Reducer.live(ClearGreeting, () => ''),
117
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
118
+ return scene(Shell, { provide: [app] })
119
+ }
120
+ export const slottedScene: typeof makeSlottedScene = makeSlottedScene
@@ -9,7 +9,8 @@ import { inMemoryTransportPair } from './memory'
9
9
  import { remoteViews } from './client'
10
10
  import { asyncCounterScene, CounterUi, counterScene } from './fixtures'
11
11
 
12
- const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
12
+ const draw = (node: ReactNode): void =>
13
+ void renderToStaticMarkup(createElement(Fragment, null, node))
13
14
 
14
15
  interface Probe {
15
16
  props?: Record<string, unknown>