@playfast/reform 0.0.8 → 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 +1 -1
- package/src/compose/composition.ts +57 -23
- package/src/compose/host.ts +7 -13
- package/src/compose/structure.test.ts +90 -0
- package/src/compose/structure.ts +145 -0
- package/src/compose/ui.test.ts +2 -30
- package/src/compose/ui.ts +21 -43
- package/src/index.ts +18 -3
- package/src/internal/capture.ts +8 -0
- package/src/internal/errors.ts +0 -12
- package/src/scene/scene.ts +26 -14
- package/src/scene/seedScene.test.ts +37 -30
- package/src/state/stateGroup.ts +20 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
|
|
7
7
|
"keywords": [
|
|
@@ -4,21 +4,30 @@ import { type Manifest, definitionClass } from '../definition/definition'
|
|
|
4
4
|
import { type Ctx } from '../internal/ctx'
|
|
5
5
|
import type { Tracker } from '../internal/track'
|
|
6
6
|
import { CurrentTracker } from '../internal/track'
|
|
7
|
-
import {
|
|
8
|
-
import type {
|
|
7
|
+
import type { SlotClass } from './slot'
|
|
8
|
+
import type { Structure } from './structure'
|
|
9
9
|
import type { UiClass, UiContract } from './ui'
|
|
10
10
|
import { Props } from './props'
|
|
11
11
|
|
|
12
|
-
/**
|
|
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. */
|
|
13
22
|
export interface RenderEnv {
|
|
14
23
|
readonly props: unknown
|
|
15
24
|
readonly tracker: Tracker
|
|
16
|
-
readonly slots: SlotHost
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
/** A mounted composition: given a render environment, produces the current frame
|
|
27
|
+
/** A mounted composition: given a render environment, produces the current frame —
|
|
28
|
+
* a {@link Structure} value (see `Frame`). */
|
|
20
29
|
export interface CompositionService {
|
|
21
|
-
readonly render: (env: RenderEnv) => Effect.Effect<
|
|
30
|
+
readonly render: (env: RenderEnv) => Effect.Effect<Frame, never, never>
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export interface CompositionConfig {
|
|
@@ -39,7 +48,20 @@ export interface CompositionConfig {
|
|
|
39
48
|
|
|
40
49
|
type PropsOf<Config> = Config extends { props: Schema.Schema<infer P, any> } ? P : unknown
|
|
41
50
|
|
|
42
|
-
|
|
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
|
+
> {
|
|
43
65
|
new (): {}
|
|
44
66
|
readonly manifest: Manifest & { readonly kind: 'Composition' } & CompositionConfig
|
|
45
67
|
/** Phantom carrying the props type for `provide(slot, composition)` checks. */
|
|
@@ -51,6 +73,11 @@ export interface CompositionClass<out P, out C extends UiContract = UiContract>
|
|
|
51
73
|
* invariant `impl` tag is never stored here, so no variance regression.
|
|
52
74
|
*/
|
|
53
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
|
|
54
81
|
readonly tag: Context.Tag<CompositionService, CompositionService>
|
|
55
82
|
}
|
|
56
83
|
|
|
@@ -66,12 +93,12 @@ export const make = <const Config extends CompositionConfig, C extends UiContrac
|
|
|
66
93
|
// manifest still sees only the erased `{ manifest }` carrier (CompositionConfig),
|
|
67
94
|
// so the manifest type — and its variance — is unchanged.
|
|
68
95
|
config: Config & { readonly ui: UiClass<C> },
|
|
69
|
-
): CompositionClass<PropsOf<Config>, C
|
|
96
|
+
): CompositionClass<PropsOf<Config>, C, StatesOf<Config>> => {
|
|
70
97
|
const tag = Context.GenericTag<CompositionService, CompositionService>(
|
|
71
98
|
`reform/composition/${name}`,
|
|
72
99
|
)
|
|
73
100
|
const manifest = { kind: 'Composition' as const, name, ...config }
|
|
74
|
-
return definitionClass<CompositionClass<PropsOf<Config>, C
|
|
101
|
+
return definitionClass<CompositionClass<PropsOf<Config>, C, StatesOf<Config>>>({ manifest, tag })
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
/**
|
|
@@ -80,33 +107,40 @@ export const make = <const Config extends CompositionConfig, C extends UiContrac
|
|
|
80
107
|
* host injects per render) surface as the Layer's `RIn`; the runtime runs the
|
|
81
108
|
* body per render within the captured context.
|
|
82
109
|
*/
|
|
83
|
-
export const live = <
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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>,
|
|
87
119
|
): Layer.Layer<CompositionService, never, Exclude<Ctx<Eff>, Props>> => {
|
|
88
120
|
const logic = Effect.gen(body)
|
|
89
121
|
return Layer.effect(
|
|
90
122
|
composition.tag,
|
|
91
123
|
Effect.gen(function* () {
|
|
92
|
-
// Capture the build-time context (state stores, calc, ui, bus). `Props
|
|
93
|
-
// the tracker
|
|
124
|
+
// Capture the build-time context (state stores, calc, ui, bus). `Props` and
|
|
125
|
+
// the tracker are injected per render below.
|
|
94
126
|
const context = yield* Effect.context<Exclude<Ctx<Eff>, Props>>()
|
|
95
|
-
const render = (env: RenderEnv): Effect.Effect<
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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.
|
|
99
134
|
logic.pipe(
|
|
100
135
|
Effect.provideService(Props, env.props),
|
|
101
136
|
Effect.provideService(CurrentTracker, env.tracker),
|
|
102
|
-
Effect.provideService(CurrentSlots, env.slots),
|
|
103
137
|
Effect.provide(context),
|
|
104
|
-
) as Effect.Effect<
|
|
138
|
+
) as unknown as Effect.Effect<Frame, never, never>
|
|
105
139
|
return { render }
|
|
106
140
|
}),
|
|
107
141
|
)
|
|
108
142
|
}
|
|
109
143
|
|
|
110
|
-
/** Run a mounted composition for one frame, producing its
|
|
111
|
-
export const render = (service: CompositionService, env: RenderEnv): Effect.Effect<
|
|
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> =>
|
|
112
146
|
service.render(env)
|
package/src/compose/host.ts
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
import { Context } from 'effect'
|
|
2
1
|
import type { Node } from './slot'
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
9
8
|
*/
|
|
10
|
-
export interface
|
|
11
|
-
|
|
9
|
+
export interface SlotRenderer {
|
|
10
|
+
(props: unknown): Node
|
|
11
|
+
readonly props: ReadonlyArray<unknown>
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
const CurrentSlotsBase: Context.TagClass<CurrentSlots, 'reform/Slots', SlotHost> =
|
|
15
|
-
Context.Tag('reform/Slots')<CurrentSlots, SlotHost>()
|
|
16
|
-
|
|
17
|
-
/** The active composition's slot bindings, provided per render by the host. */
|
|
18
|
-
export class CurrentSlots extends CurrentSlotsBase {}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Schema as S } from 'effect'
|
|
2
|
+
import { describe, expect, test } from 'vitest'
|
|
3
|
+
import { Composition } from '../index'
|
|
4
|
+
import { slot } from './slot'
|
|
5
|
+
import { type Contract, ui } from './ui'
|
|
6
|
+
import { absent, each, isStructure, mount, one, structureEquals, when } from './structure'
|
|
7
|
+
|
|
8
|
+
type List = Contract<typeof ListUi>
|
|
9
|
+
|
|
10
|
+
// A minimal parent/child contract pair: a list parent with one keyed slot, so the
|
|
11
|
+
// type-level `mount` checks have a real contract to bind against. Row carries a
|
|
12
|
+
// props schema so its external props (`Comp['Props']`) are `{ label: string }`,
|
|
13
|
+
// which is what each fill is checked against.
|
|
14
|
+
class RowUi extends ui('Row')<{ props: { label: string } }>() {}
|
|
15
|
+
class Row extends Composition.make('Row', {
|
|
16
|
+
title: 'Row',
|
|
17
|
+
props: S.Struct({ label: S.String }),
|
|
18
|
+
ui: RowUi,
|
|
19
|
+
}) {}
|
|
20
|
+
class RowSlot extends slot('Row')<typeof Row>() {}
|
|
21
|
+
|
|
22
|
+
class ListUi extends ui('List')<{
|
|
23
|
+
props: { total: number }
|
|
24
|
+
slots: { Row: RowSlot }
|
|
25
|
+
}>() {}
|
|
26
|
+
|
|
27
|
+
describe('structure combinators', () => {
|
|
28
|
+
test('each produces one keyed entry per item, props mapped', () => {
|
|
29
|
+
const fill = each([{ id: 'a' }, { id: 'b' }], { key: (t) => t.id, props: (t) => ({ label: t.id }) })
|
|
30
|
+
expect(fill).toEqual({
|
|
31
|
+
_tag: 'Each',
|
|
32
|
+
items: [
|
|
33
|
+
{ key: 'a', props: { label: 'a' } },
|
|
34
|
+
{ key: 'b', props: { label: 'b' } },
|
|
35
|
+
],
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('one and absent / when', () => {
|
|
40
|
+
expect(one({ label: 'x' })).toEqual({ _tag: 'One', props: { label: 'x' } })
|
|
41
|
+
expect(absent).toEqual({ _tag: 'Absent' })
|
|
42
|
+
expect(when(true, one({ label: 'y' }))).toEqual({ _tag: 'One', props: { label: 'y' } })
|
|
43
|
+
expect(when(false, one({ label: 'y' }))).toEqual({ _tag: 'Absent' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('mount stamps the Structure brand; isStructure discriminates it from a node', () => {
|
|
47
|
+
const s = mount<List>({
|
|
48
|
+
props: { total: 2 },
|
|
49
|
+
slots: { Row: each([{ id: 'a' }], { key: (t) => t.id, props: (t) => ({ label: t.id }) }) },
|
|
50
|
+
})
|
|
51
|
+
expect(isStructure(s)).toBe(true)
|
|
52
|
+
expect(isStructure({ type: 'div' })).toBe(false)
|
|
53
|
+
expect(isStructure(null)).toBe(false)
|
|
54
|
+
expect(s.props).toEqual({ total: 2 })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('structureEquals detects a two-frame fixpoint vs a change', () => {
|
|
58
|
+
const make = (label: string) =>
|
|
59
|
+
mount<List>({
|
|
60
|
+
props: { total: 1 },
|
|
61
|
+
slots: { Row: each([{ id: 'a' }], { key: (t) => t.id, props: () => ({ label }) }) },
|
|
62
|
+
})
|
|
63
|
+
expect(structureEquals(make('same'), make('same'))).toBe(true)
|
|
64
|
+
expect(structureEquals(make('a'), make('b'))).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Type-level contract enforcement — never called; exists only so the negative
|
|
69
|
+
// cases are typechecked. Each `@ts-expect-error` must sit on the line that errors.
|
|
70
|
+
export const _typeChecks = (): void => {
|
|
71
|
+
mount<List>({
|
|
72
|
+
props: { total: 1 },
|
|
73
|
+
slots: {
|
|
74
|
+
// @ts-expect-error 'Nope' is not a declared slot of ListUi
|
|
75
|
+
Nope: one({ label: 'x' }),
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
mount<List>({
|
|
79
|
+
props: { total: 1 },
|
|
80
|
+
slots: {
|
|
81
|
+
// @ts-expect-error Row's child props are { label: string }, not { wrong: number }
|
|
82
|
+
Row: each([1], { key: () => 'k', props: () => ({ wrong: 1 }) }),
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
mount<List>({
|
|
86
|
+
// @ts-expect-error List's props are { total: number }, not { total: string }
|
|
87
|
+
props: { total: 'two' },
|
|
88
|
+
slots: { Row: absent },
|
|
89
|
+
})
|
|
90
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { SlotProps } from './slot'
|
|
2
|
+
import type { EventsOf, UiContract } from './ui'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structure-as-data (the headless-structure keystone). A composition's logic
|
|
6
|
+
* body returns this *value* instead of a React node: pure, serializable data that
|
|
7
|
+
* describes — for this frame — the composition's own props and, per declared slot,
|
|
8
|
+
* how that slot is filled (a list / a singleton / absent). The engine, the wire
|
|
9
|
+
* server, and the proof harness consume this directly, so none of them needs to
|
|
10
|
+
* evaluate a view or import React to discover structure. Presentation (React)
|
|
11
|
+
* resolves a contract's markup separately, by name, on a host.
|
|
12
|
+
*
|
|
13
|
+
* `live` accepts BOTH a `Node` (legacy view body) and a `Structure` during the
|
|
14
|
+
* migration; hosts discriminate at runtime via {@link isStructure}.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A list slot fill: one keyed entry per item. `key` is the SINGLE source of both
|
|
18
|
+
* the wire `key` and the per-item `StateFamily` key — they can no longer drift. */
|
|
19
|
+
export interface EachFill<P> {
|
|
20
|
+
readonly _tag: 'Each'
|
|
21
|
+
readonly items: ReadonlyArray<{ readonly key: string; readonly props: P }>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A singleton slot fill — the slot renders exactly one child with these props. */
|
|
25
|
+
export interface OneFill<P> {
|
|
26
|
+
readonly _tag: 'One'
|
|
27
|
+
readonly props: P
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A slot that renders nothing this frame (the data-driven analog of `cond && …`). */
|
|
31
|
+
export interface AbsentFill {
|
|
32
|
+
readonly _tag: 'Absent'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How one declared slot is filled this frame, typed by the child's external props. */
|
|
36
|
+
export type SlotFill<P> = EachFill<P> | OneFill<P> | AbsentFill
|
|
37
|
+
|
|
38
|
+
/** The per-slot fill map a contract's structure carries — one fill per declared slot,
|
|
39
|
+
* each typed by that child composition's external props (`SlotProps`). Empty when the
|
|
40
|
+
* contract declares no slots. */
|
|
41
|
+
export type StructureSlots<C extends UiContract> = C extends {
|
|
42
|
+
slots: infer S
|
|
43
|
+
}
|
|
44
|
+
? { readonly [K in keyof S]: SlotFill<SlotProps<S[K]>> }
|
|
45
|
+
: Record<never, never>
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The data a composition's logic returns for one frame: its computed props plus,
|
|
49
|
+
* for every declared slot, a fill description. Strict against the contract — props
|
|
50
|
+
* must match, and every declared slot must be filled (use {@link when} for
|
|
51
|
+
* conditional presence). Carries the child's contract per slot (via `SlotProps`),
|
|
52
|
+
* so consumers can select and descend into sub-compositions type-safely.
|
|
53
|
+
*
|
|
54
|
+
* `events` rides ON the structure: because a `mount(...)`-returning body never
|
|
55
|
+
* calls its view, the event triggers it acquired (`yield* Event.trigger(X)`) are
|
|
56
|
+
* no longer captured by a view's `CaptureSink` record — they must travel with the
|
|
57
|
+
* frame so hosts can register them (proof `actions`, the wire `TriggerRegistry`,
|
|
58
|
+
* the react presentation `events` arg). Triggers are functions, so `events` is NOT
|
|
59
|
+
* serializable and is intentionally excluded from {@link structureEquals}. Typed to
|
|
60
|
+
* the contract's event map; omit it (defaults to `{}`) when the contract has none.
|
|
61
|
+
*/
|
|
62
|
+
export interface Structure<C extends UiContract> {
|
|
63
|
+
readonly props: C['props']
|
|
64
|
+
readonly slots: StructureSlots<C>
|
|
65
|
+
readonly events?: EventsOf<C>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Runtime brand distinguishing a `Structure` value from a React `Node` (added by
|
|
69
|
+
* {@link mount}; the authoring TYPE intentionally omits it so structure literals stay
|
|
70
|
+
* plain). */
|
|
71
|
+
export const StructureTypeId: unique symbol = Symbol.for('reform/Structure')
|
|
72
|
+
export type StructureTypeId = typeof StructureTypeId
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Assemble a frame's structure, checked against the contract `C`. Identity at
|
|
76
|
+
* runtime apart from stamping the {@link StructureTypeId} brand; its real job is
|
|
77
|
+
* the compile-time contract check. `C` is recovered from the surrounding `live`
|
|
78
|
+
* body's return type (contextual typing), so call sites read `mount({ props, slots })`.
|
|
79
|
+
* A leaf composition (no declared slots) passes `slots: {}`. `events` defaults to an
|
|
80
|
+
* empty map when omitted, so a frame always carries an `events` record for hosts.
|
|
81
|
+
*/
|
|
82
|
+
export const mount = <C extends UiContract>(structure: Structure<C>): Structure<C> =>
|
|
83
|
+
Object.assign({ [StructureTypeId]: StructureTypeId, events: {} }, structure)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A list slot fill. `key` derives the stable identity for BOTH the wire node and
|
|
87
|
+
* the per-item state family — passing the same id source to both is no longer
|
|
88
|
+
* possible to get wrong. `props` maps each item to the child's external props.
|
|
89
|
+
*/
|
|
90
|
+
export const each = <T, P>(
|
|
91
|
+
collection: Iterable<T>,
|
|
92
|
+
options: { readonly key: (item: T) => string; readonly props: (item: T) => P },
|
|
93
|
+
): EachFill<P> => ({
|
|
94
|
+
_tag: 'Each',
|
|
95
|
+
items: Array.from(collection, (item) => ({ key: options.key(item), props: options.props(item) })),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
/** A singleton slot fill — render one child with these external props. */
|
|
99
|
+
export const one = <P>(props: P): OneFill<P> => ({ _tag: 'One', props })
|
|
100
|
+
|
|
101
|
+
/** The shared empty fill — a slot that renders nothing this frame. */
|
|
102
|
+
export const absent: AbsentFill = { _tag: 'Absent' }
|
|
103
|
+
|
|
104
|
+
/** Conditional presence: the fill when `cond`, otherwise {@link absent}. */
|
|
105
|
+
export const when = <F extends SlotFill<unknown>>(cond: boolean, fill: F): F | AbsentFill =>
|
|
106
|
+
cond ? fill : absent
|
|
107
|
+
|
|
108
|
+
/** Whether a frame value is a `Structure` (vs a legacy React `Node`). The runtime
|
|
109
|
+
* discriminator hosts use during the migration, replacing React's `isValidElement`. */
|
|
110
|
+
export const isStructure = (frame: unknown): frame is Structure<UiContract> =>
|
|
111
|
+
typeof frame === 'object' && frame !== null && StructureTypeId in frame
|
|
112
|
+
|
|
113
|
+
/** Whether a value is a plain record (not an array) — narrows for the deep compare
|
|
114
|
+
* below without a cast. */
|
|
115
|
+
const isRecord = (u: unknown): u is Record<string, unknown> =>
|
|
116
|
+
typeof u === 'object' && u !== null && !Array.isArray(u)
|
|
117
|
+
|
|
118
|
+
/** Minimal structural deep-equality for serializable shapes (plain JSON — the
|
|
119
|
+
* UI-contract guarantee). Avoids a heavyweight dep for the settle comparison. */
|
|
120
|
+
const deepEquals = (a: unknown, b: unknown): boolean => {
|
|
121
|
+
if (Object.is(a, b)) return true
|
|
122
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
123
|
+
return a.length === b.length && a.every((item, index) => deepEquals(item, b[index]))
|
|
124
|
+
}
|
|
125
|
+
if (isRecord(a) && isRecord(b)) {
|
|
126
|
+
const aKeys = Object.keys(a)
|
|
127
|
+
const bKeys = Object.keys(b)
|
|
128
|
+
return (
|
|
129
|
+
aKeys.length === bKeys.length &&
|
|
130
|
+
aKeys.every(
|
|
131
|
+
(key) => Object.prototype.hasOwnProperty.call(b, key) && deepEquals(a[key], b[key]),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Structural equality over two structures — props and every slot fill compared by
|
|
139
|
+
* value. `events` is intentionally excluded: triggers are functions, freshly
|
|
140
|
+
* acquired each render, so comparing them by identity would never match and would
|
|
141
|
+
* break the settle fixpoint. The `StructureTypeId` brand is a symbol key, so
|
|
142
|
+
* `Object.keys` skips it. Used by the proof/server settle loop to detect a
|
|
143
|
+
* two-frame fixpoint. */
|
|
144
|
+
export const structureEquals = (a: Structure<UiContract>, b: Structure<UiContract>): boolean =>
|
|
145
|
+
deepEquals(a.props, b.props) && deepEquals(a.slots, b.slots)
|
package/src/compose/ui.test.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { expect, test } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { CaptureSink, type UiCapture } from '../internal/capture'
|
|
5
|
-
import type { Trigger } from '../ui/trigger'
|
|
2
|
+
import { Schema as S } from 'effect'
|
|
3
|
+
import { isUi, ui } from '../index'
|
|
6
4
|
|
|
7
5
|
// `ui` has two authoring forms with one source of truth each: the type-only form
|
|
8
6
|
// (local, no schema) and the schema form (wired, the type is *derived*). Both are
|
|
@@ -34,29 +32,3 @@ test('schema `ui` with no events omits the events schema', () => {
|
|
|
34
32
|
expect(S.isSchema(TitleUi.manifest.props)).toBe(true)
|
|
35
33
|
expect(TitleUi.manifest.events).toBeUndefined()
|
|
36
34
|
})
|
|
37
|
-
|
|
38
|
-
test('a contract resolves its presentation and reports to a capturing sink', () => {
|
|
39
|
-
class CounterUi extends ui('Counter', {
|
|
40
|
-
props: S.Struct({ count: S.Number }),
|
|
41
|
-
events: { bump: S.Struct({ by: S.Number }) },
|
|
42
|
-
}) {}
|
|
43
|
-
|
|
44
|
-
const view = Ui.make(CounterUi, ({ count }) => `count:${count}`)
|
|
45
|
-
const records: UiCapture[] = []
|
|
46
|
-
const sink = { record: (capture: UiCapture): void => void records.push(capture) }
|
|
47
|
-
|
|
48
|
-
const logicView = Effect.runSync(
|
|
49
|
-
CounterUi.pipe(
|
|
50
|
-
Effect.provideService(CounterUi.impl, view),
|
|
51
|
-
Effect.provideService(CaptureSink, sink),
|
|
52
|
-
),
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
const bump: Trigger<{ by: number }> = () => {}
|
|
56
|
-
const node = logicView({ count: 3 }, { bump })
|
|
57
|
-
|
|
58
|
-
expect(node).toBe('count:3')
|
|
59
|
-
expect(records).toHaveLength(1)
|
|
60
|
-
expect(records[0]?.name).toBe('Counter')
|
|
61
|
-
expect(records[0]?.props).toEqual({ count: 3 })
|
|
62
|
-
})
|
package/src/compose/ui.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import type { FunctionComponent } from 'react'
|
|
2
|
-
import { Context,
|
|
3
|
-
import { type Manifest,
|
|
4
|
-
import { CaptureSink } from '../internal/capture'
|
|
5
|
-
import { SlotRenderingUnavailable } from '../internal/errors'
|
|
2
|
+
import { Context, type Schema } from 'effect'
|
|
3
|
+
import { type Manifest, definitionClass } from '../definition/definition'
|
|
6
4
|
import type { Trigger } from '../ui/trigger'
|
|
7
|
-
import { CurrentSlots } from './host'
|
|
8
5
|
import { type Node, type SlotClass, type SlotInstance, type SlotProps } from './slot'
|
|
9
6
|
|
|
10
7
|
export interface UiContract {
|
|
@@ -14,7 +11,10 @@ export interface UiContract {
|
|
|
14
11
|
}
|
|
15
12
|
|
|
16
13
|
type PropsOf<C extends UiContract> = C['props']
|
|
17
|
-
|
|
14
|
+
/** The contract's event map (`{ [name]: Trigger<P> }`), or empty when it declares
|
|
15
|
+
* none. Exported so a `Structure<C>` can type its optional `events` field to the
|
|
16
|
+
* same map a composition body acquires. */
|
|
17
|
+
export type EventsOf<C extends UiContract> = C extends { events: infer E } ? E : Record<never, never>
|
|
18
18
|
// A slot is a React COMPONENT whose props are OPTIONAL (a `Partial`): a parent that
|
|
19
19
|
// drives a child supplies them (`<slots.Item id=… />` / `createElement(slots.Item, {…})`),
|
|
20
20
|
// but a placeholder render (the remote client, where the per-child props were captured
|
|
@@ -35,15 +35,18 @@ type SlotsOf<C extends UiContract> = C extends { slots: infer S }
|
|
|
35
35
|
// child props are `any` the whole thing still collapses to `any` (`any & X = any`), keeping
|
|
36
36
|
// the component assignable to a bare `(props: unknown) => Node` slot-renderer shape. An
|
|
37
37
|
// outer intersection would leave a required-shape object that `unknown` can't satisfy.
|
|
38
|
+
//
|
|
39
|
+
// A slot handle is BOTH renderable (the `FunctionComponent` — places the keyed children)
|
|
40
|
+
// AND inspectable (`.props` — the array of per-child fill props the structure carried, one
|
|
41
|
+
// entry per mounted child, typed by the child contract). A presentation can read the fills
|
|
42
|
+
// (`slots.Item.props.length`, a header, layout decisions) and still render them
|
|
43
|
+
// (`<slots.Item/>`); it never DERIVES multiplicity — the engine already fixed it. Read-only.
|
|
38
44
|
readonly [K in keyof S]: FunctionComponent<
|
|
39
45
|
Partial<SlotProps<S[K]> & { readonly slotKey?: string }>
|
|
40
|
-
>
|
|
46
|
+
> & { readonly props: ReadonlyArray<SlotProps<S[K]>> }
|
|
41
47
|
}
|
|
42
48
|
: Record<never, never>
|
|
43
49
|
|
|
44
|
-
/** What the composition logic calls: props + handlers in, a node out. Slots are bound by the renderer. */
|
|
45
|
-
export type LogicView<C extends UiContract> = (props: PropsOf<C>, events?: EventsOf<C>) => Node
|
|
46
|
-
|
|
47
50
|
/** What `.make` authors: the pure presentation. Slots are injected by the renderer. */
|
|
48
51
|
export type ViewImpl<C extends UiContract> = (
|
|
49
52
|
props: PropsOf<C>,
|
|
@@ -73,8 +76,9 @@ export interface WiredUiManifest extends UiManifest {
|
|
|
73
76
|
readonly props: Schema.Schema<any, any>
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
// A branded, extendable class carrying the wire `manifest` + the DI `impl` tag. Not
|
|
80
|
+
// yieldable: bodies return a `Structure`, and the host reads `impl` to resolve the view.
|
|
81
|
+
export interface UiClass<C extends UiContract> {
|
|
78
82
|
new (): {}
|
|
79
83
|
readonly [UiTypeId]: UiTypeId
|
|
80
84
|
readonly manifest: UiManifest
|
|
@@ -144,34 +148,9 @@ const buildUi = <C extends UiContract, M extends UiManifest>(
|
|
|
144
148
|
manifest: M,
|
|
145
149
|
): UiClass<C> & { readonly manifest: M } => {
|
|
146
150
|
const impl = Context.GenericTag<ViewImpl<C>, ViewImpl<C>>(`reform/ui/${name}`)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// Core has no renderer, so without a host every slot access throws.
|
|
151
|
-
const host = yield* Effect.serviceOption(CurrentSlots)
|
|
152
|
-
const slots = new Proxy({} as SlotsOf<C>, {
|
|
153
|
-
get(_t, key) {
|
|
154
|
-
if (Option.isNone(host)) {
|
|
155
|
-
throw new SlotRenderingUnavailable({ slot: String(key) })
|
|
156
|
-
}
|
|
157
|
-
return host.value.slot(String(key))
|
|
158
|
-
},
|
|
159
|
-
})
|
|
160
|
-
// A capturing host (proofs, the dev tool) reports each render's observable
|
|
161
|
-
// surface without replacing the presentation. Absent in production.
|
|
162
|
-
const sink = yield* Effect.serviceOption(CaptureSink)
|
|
163
|
-
return (props: PropsOf<C>, events?: EventsOf<C>) => {
|
|
164
|
-
if (Option.isSome(sink)) {
|
|
165
|
-
sink.value.record({
|
|
166
|
-
name,
|
|
167
|
-
props,
|
|
168
|
-
events: (events ?? {}) as Record<string, Trigger<unknown>>,
|
|
169
|
-
})
|
|
170
|
-
}
|
|
171
|
-
return render(props, slots, (events ?? {}) as EventsOf<C>)
|
|
172
|
-
}
|
|
173
|
-
})
|
|
174
|
-
return yieldableClass(read, {
|
|
151
|
+
// Extendable definition class (`class Counter extends ui('Counter', …) {}`); resolved
|
|
152
|
+
// by name on a host via `impl`, never yielded.
|
|
153
|
+
return definitionClass<UiClass<C> & { readonly manifest: M }>({
|
|
175
154
|
[UiTypeId]: UiTypeId,
|
|
176
155
|
manifest,
|
|
177
156
|
impl,
|
|
@@ -179,9 +158,8 @@ const buildUi = <C extends UiContract, M extends UiManifest>(
|
|
|
179
158
|
}
|
|
180
159
|
|
|
181
160
|
/**
|
|
182
|
-
* A renderer-neutral UI contract
|
|
183
|
-
*
|
|
184
|
-
* presentation. Core defines the contract; `@reform/react` does the rendering.
|
|
161
|
+
* A renderer-neutral UI contract: `.make` authors the presentation, a composition
|
|
162
|
+
* names it, and `@reform/react` resolves it to a view. Core defines the contract.
|
|
185
163
|
*
|
|
186
164
|
* Two authoring forms, one source of truth each:
|
|
187
165
|
*
|
package/src/index.ts
CHANGED
|
@@ -56,8 +56,25 @@ export * as Composition from './compose/composition'
|
|
|
56
56
|
export type {
|
|
57
57
|
CompositionClass,
|
|
58
58
|
CompositionService,
|
|
59
|
+
Frame,
|
|
59
60
|
RenderEnv,
|
|
60
61
|
} from './compose/composition'
|
|
62
|
+
export {
|
|
63
|
+
absent,
|
|
64
|
+
each,
|
|
65
|
+
type EachFill,
|
|
66
|
+
isStructure,
|
|
67
|
+
mount,
|
|
68
|
+
one,
|
|
69
|
+
type OneFill,
|
|
70
|
+
type AbsentFill,
|
|
71
|
+
type SlotFill,
|
|
72
|
+
type Structure,
|
|
73
|
+
structureEquals,
|
|
74
|
+
type StructureSlots,
|
|
75
|
+
StructureTypeId,
|
|
76
|
+
when,
|
|
77
|
+
} from './compose/structure'
|
|
61
78
|
export { Props } from './compose/props'
|
|
62
79
|
export {
|
|
63
80
|
isSlot,
|
|
@@ -68,12 +85,11 @@ export {
|
|
|
68
85
|
type SlotProps,
|
|
69
86
|
SlotTypeId,
|
|
70
87
|
} from './compose/slot'
|
|
71
|
-
export {
|
|
88
|
+
export { type SlotRenderer } from './compose/host'
|
|
72
89
|
export * as Ui from './compose/ui'
|
|
73
90
|
export {
|
|
74
91
|
type DerivedContract,
|
|
75
92
|
isUi,
|
|
76
|
-
type LogicView,
|
|
77
93
|
type MadeView,
|
|
78
94
|
ui,
|
|
79
95
|
type UiClass,
|
|
@@ -151,7 +167,6 @@ export {
|
|
|
151
167
|
FeatureLoadFailed,
|
|
152
168
|
forceSync,
|
|
153
169
|
InvalidProvideTarget,
|
|
154
|
-
SlotRenderingUnavailable,
|
|
155
170
|
UnknownGroupState,
|
|
156
171
|
} from './internal/errors'
|
|
157
172
|
// Notification scheduler: hosts that need per-runtime isolation (concurrent SSR,
|
package/src/internal/capture.ts
CHANGED
|
@@ -10,6 +10,14 @@ export interface UiCapture {
|
|
|
10
10
|
readonly name: string
|
|
11
11
|
readonly props: unknown
|
|
12
12
|
readonly events: Record<string, Trigger<unknown>>
|
|
13
|
+
/**
|
|
14
|
+
* The stable slot-fill key this render was mounted under (an `each` item's
|
|
15
|
+
* `key`, or a synthesized singleton key for a `one`). Present only on the
|
|
16
|
+
* structure path — the legacy Node path defers slot expansion to host
|
|
17
|
+
* components and has no fill key, so it is omitted there. Lets the proof
|
|
18
|
+
* facade select a child by key (`byKey`) instead of by render index.
|
|
19
|
+
*/
|
|
20
|
+
readonly key?: string
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/** Where captured renders are reported, when a capturing host is present. */
|
package/src/internal/errors.ts
CHANGED
|
@@ -19,18 +19,6 @@ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = n
|
|
|
19
19
|
/** The fieldless variant — a `Data.TaggedError(tag)<{}>` constructor. */
|
|
20
20
|
type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & { readonly _tag: Tag }
|
|
21
21
|
|
|
22
|
-
const SlotRenderingUnavailableBase: TaggedErrorClass<
|
|
23
|
-
'reform/SlotRenderingUnavailable',
|
|
24
|
-
{ readonly slot: string }
|
|
25
|
-
> = Data.TaggedError('reform/SlotRenderingUnavailable')<{ readonly slot: string }>
|
|
26
|
-
|
|
27
|
-
/** A `<slots.X/>` was rendered without a render-target host to realize it. */
|
|
28
|
-
export class SlotRenderingUnavailable extends SlotRenderingUnavailableBase {
|
|
29
|
-
override get message(): string {
|
|
30
|
-
return `reform: rendering slot '${this.slot}' requires @reform/react`
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
22
|
const InvalidProvideTargetBase: EmptyTaggedErrorClass<'reform/InvalidProvideTarget'> =
|
|
35
23
|
Data.TaggedError('reform/InvalidProvideTarget')<{}>
|
|
36
24
|
|
package/src/scene/scene.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Layer } from 'effect'
|
|
2
2
|
import type { CompositionClass, CompositionService } from '../compose/composition'
|
|
3
3
|
import type { UiContract } from '../compose/ui'
|
|
4
|
+
import type { EventOf } from '../event/event'
|
|
4
5
|
import { CurrentSeedOverrides } from '../internal/seeds'
|
|
5
|
-
import type {
|
|
6
|
+
import type { SeedsOf } from '../state/stateGroup'
|
|
7
|
+
import type { Bus } from '../runtime/bus'
|
|
6
8
|
|
|
7
9
|
// A scene is a VALUE — a composition plus the closed wiring that runs it: the
|
|
8
10
|
// layers that supply its logic/views (each seeding its own live state) and the
|
|
@@ -23,27 +25,37 @@ import type { Bus, Tagged } from '../runtime/bus'
|
|
|
23
25
|
*/
|
|
24
26
|
export type MountedServices = CompositionService | Bus
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
// A scene's boot list holds CONSTRUCTED events (`Event.construct(E, payload)`),
|
|
29
|
+
// not bare tagged objects — a lightweight nudge toward the real builder. It is
|
|
30
|
+
// not a guarantee the event is handled by the wiring (reform carries no
|
|
31
|
+
// type-level union of handled tags), only that it came from an event definition.
|
|
32
|
+
type BootEvent = EventOf<string, unknown>
|
|
33
|
+
|
|
34
|
+
export interface Scene<
|
|
35
|
+
C extends UiContract = UiContract,
|
|
36
|
+
S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
|
|
37
|
+
> {
|
|
27
38
|
readonly kind: 'Scene'
|
|
28
|
-
/** The composition to run, with its contract preserved for typed consumers. */
|
|
29
|
-
readonly composition: CompositionClass<unknown, C>
|
|
39
|
+
/** The composition to run, with its contract + states preserved for typed consumers. */
|
|
40
|
+
readonly composition: CompositionClass<unknown, C, S>
|
|
30
41
|
/** Closed wiring (logic + views), each layer seeding its own live state. */
|
|
31
42
|
readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
|
|
32
43
|
/** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
|
|
33
|
-
readonly boot?: ReadonlyArray<
|
|
44
|
+
readonly boot?: ReadonlyArray<BootEvent>
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
/**
|
|
37
48
|
* Define a scene — `scene(TodoApp, { provide: [makeTestApp(client, seeds)] })`.
|
|
38
|
-
* The composition's contract
|
|
49
|
+
* The composition's contract `C` and state tuple `S` flow through, so consumers
|
|
50
|
+
* (the facade, `seedScene`) stay typed.
|
|
39
51
|
*/
|
|
40
|
-
export const scene = <C extends UiContract
|
|
41
|
-
composition: CompositionClass<unknown, C>,
|
|
52
|
+
export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
53
|
+
composition: CompositionClass<unknown, C, S>,
|
|
42
54
|
config: {
|
|
43
55
|
readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
|
|
44
|
-
readonly boot?: ReadonlyArray<
|
|
56
|
+
readonly boot?: ReadonlyArray<BootEvent>
|
|
45
57
|
},
|
|
46
|
-
): Scene<C> => ({
|
|
58
|
+
): Scene<C, S> => ({
|
|
47
59
|
kind: 'Scene',
|
|
48
60
|
composition,
|
|
49
61
|
provide: config.provide,
|
|
@@ -63,10 +75,10 @@ export const scene = <C extends UiContract>(
|
|
|
63
75
|
* an invalid value silently falls back to the authored seed. `StateFamily`
|
|
64
76
|
* entries are not covered.
|
|
65
77
|
*/
|
|
66
|
-
export const seedScene = <C extends UiContract
|
|
67
|
-
base: Scene<C>,
|
|
68
|
-
seeds:
|
|
69
|
-
): Scene<C> =>
|
|
78
|
+
export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
79
|
+
base: Scene<C, S>,
|
|
80
|
+
seeds: SeedsOf<S>,
|
|
81
|
+
): Scene<C, S> =>
|
|
70
82
|
Object.keys(seeds).length === 0
|
|
71
83
|
? base
|
|
72
84
|
: { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
|
|
2
2
|
import { expect, test } from 'vitest'
|
|
3
3
|
import {
|
|
4
|
-
type CaptureSinkApi,
|
|
5
|
-
CaptureSink,
|
|
6
4
|
Composition,
|
|
7
5
|
Engine,
|
|
8
6
|
Event,
|
|
7
|
+
isStructure,
|
|
8
|
+
mount,
|
|
9
9
|
provide,
|
|
10
10
|
publish,
|
|
11
11
|
Reducer,
|
|
@@ -18,13 +18,14 @@ import {
|
|
|
18
18
|
Ui,
|
|
19
19
|
ui,
|
|
20
20
|
} from '../index'
|
|
21
|
+
import { CurrentSeedOverrides } from '../internal/seeds'
|
|
21
22
|
|
|
22
23
|
// `seedScene` is the tooling seam over CLOSED scenes: the app bundle below seeds
|
|
23
24
|
// `count: 1` itself (no open store), and the overlay must reach the nested
|
|
24
25
|
// `State.live` through `Layer.locally(CurrentSeedOverrides, …)` on the scene's
|
|
25
26
|
// pre-composed layers. The harness mirrors the dev-tool preview: merge the
|
|
26
27
|
// scene's `provide`, mount it in a ManagedRuntime, and read the root
|
|
27
|
-
// composition's rendered props through the headless
|
|
28
|
+
// composition's rendered props through the headless Structure.
|
|
28
29
|
|
|
29
30
|
class CountState extends State.make('count', S.Number) {}
|
|
30
31
|
class MiniStates extends StateGroup.make(CountState) {}
|
|
@@ -46,8 +47,7 @@ class Counter extends Composition.make('Counter', {
|
|
|
46
47
|
}) {}
|
|
47
48
|
const CounterLive = Composition.live(Counter, function* () {
|
|
48
49
|
const count = yield* StateGroup.select(MiniStates, 'count')
|
|
49
|
-
|
|
50
|
-
return view({ count })
|
|
50
|
+
return mount({ props: { count }, slots: {} })
|
|
51
51
|
})
|
|
52
52
|
|
|
53
53
|
// The closed app layer a scene provides: views + logic + Engine, with the state
|
|
@@ -61,37 +61,44 @@ const MiniApp = Views.pipe(Layer.provideMerge(Logic))
|
|
|
61
61
|
|
|
62
62
|
const CounterScene = scene(Counter, { provide: [MiniApp] })
|
|
63
63
|
|
|
64
|
+
// The dev-tool inspector overlays raw, user-typed seeds (`Record<string, unknown>`)
|
|
65
|
+
// — the untyped seam that `seedScene` wraps once a composition's state tuple is
|
|
66
|
+
// known. These two cases (absent key / schema-invalid value) are compile errors
|
|
67
|
+
// through the now-typed `seedScene`, so they're exercised here at the raw overlay
|
|
68
|
+
// to prove `resolveSeed`'s runtime defensiveness still falls back to the seed.
|
|
69
|
+
const overlayRawSeeds = (base: Scene, seeds: Readonly<Record<string, unknown>>): Scene => ({
|
|
70
|
+
...base,
|
|
71
|
+
provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)),
|
|
72
|
+
})
|
|
73
|
+
|
|
64
74
|
const headlessEnv: RenderEnv = {
|
|
65
75
|
props: {},
|
|
66
76
|
tracker: { add: () => {} },
|
|
67
|
-
slots: { slot: () => () => null },
|
|
68
77
|
}
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
const isCountProps = (props: unknown): props is { readonly count: number } =>
|
|
80
|
+
typeof props === 'object' &&
|
|
81
|
+
props !== null &&
|
|
82
|
+
'count' in props &&
|
|
83
|
+
typeof props.count === 'number'
|
|
84
|
+
|
|
85
|
+
// One ManagedRuntime per scene under test. Each `read` re-renders the root
|
|
86
|
+
// composition and reads the Structure built from the stores that runtime actually
|
|
87
|
+
// constructed.
|
|
74
88
|
const makeHarness = (s: Scene) => {
|
|
75
|
-
const
|
|
76
|
-
const sink: CaptureSinkApi = {
|
|
77
|
-
record: (capture) => {
|
|
78
|
-
const props = capture.props
|
|
79
|
-
if (
|
|
80
|
-
typeof props === 'object' &&
|
|
81
|
-
props !== null &&
|
|
82
|
-
'count' in props &&
|
|
83
|
-
typeof props.count === 'number'
|
|
84
|
-
) {
|
|
85
|
-
captured.count = props.count
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
|
-
}
|
|
89
|
-
const layer = s.provide
|
|
90
|
-
.reduce((a, b) => Layer.merge(a, b))
|
|
91
|
-
.pipe(Layer.provideMerge(Layer.succeed(CaptureSink, sink)))
|
|
89
|
+
const layer = s.provide.reduce((a, b) => Layer.merge(a, b))
|
|
92
90
|
const runtime = ManagedRuntime.make(layer)
|
|
93
91
|
const read = Effect.flatMap(Counter.tag, (c) =>
|
|
94
|
-
Composition.render(c, headlessEnv).pipe(
|
|
92
|
+
Composition.render(c, headlessEnv).pipe(
|
|
93
|
+
Effect.flatMap((frame) => {
|
|
94
|
+
if (!isStructure(frame)) {
|
|
95
|
+
return Effect.dieMessage('Counter must render a Structure')
|
|
96
|
+
}
|
|
97
|
+
return isCountProps(frame.props)
|
|
98
|
+
? Effect.succeed(frame.props.count)
|
|
99
|
+
: Effect.dieMessage('Counter Structure props must include numeric count')
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
95
102
|
)
|
|
96
103
|
return { runtime, read }
|
|
97
104
|
}
|
|
@@ -134,7 +141,7 @@ test('the live reducer drives the SAME overridden store', async () => {
|
|
|
134
141
|
})
|
|
135
142
|
|
|
136
143
|
test('an absent key leaves the authored seed in place', async () => {
|
|
137
|
-
const { runtime, read } = makeHarness(
|
|
144
|
+
const { runtime, read } = makeHarness(overlayRawSeeds(CounterScene, { other: 1 }))
|
|
138
145
|
try {
|
|
139
146
|
expect(await runtime.runPromise(read)).toBe(1)
|
|
140
147
|
} finally {
|
|
@@ -143,7 +150,7 @@ test('an absent key leaves the authored seed in place', async () => {
|
|
|
143
150
|
})
|
|
144
151
|
|
|
145
152
|
test('a schema-invalid override falls back to the authored seed', async () => {
|
|
146
|
-
const { runtime, read } = makeHarness(
|
|
153
|
+
const { runtime, read } = makeHarness(overlayRawSeeds(CounterScene, { count: 'not-a-number' }))
|
|
147
154
|
try {
|
|
148
155
|
expect(await runtime.runPromise(read)).toBe(1)
|
|
149
156
|
} finally {
|
package/src/state/stateGroup.ts
CHANGED
|
@@ -51,6 +51,26 @@ export type GroupSeeds<G extends AnyStateGroup> =
|
|
|
51
51
|
? { readonly [N in StateName<Members[number]>]: ValueForName<Members, N> }
|
|
52
52
|
: never
|
|
53
53
|
|
|
54
|
+
// Collapse a union of records into their intersection — merges every group's
|
|
55
|
+
// seed record into one. (The classic contravariant-position trick.)
|
|
56
|
+
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
|
|
57
|
+
k: infer I,
|
|
58
|
+
) => void
|
|
59
|
+
? I
|
|
60
|
+
: never
|
|
61
|
+
|
|
62
|
+
/** The seed record for one tuple entry: a group's `GroupSeeds`, else nothing. */
|
|
63
|
+
type EntrySeeds<G> = G extends AnyStateGroup ? GroupSeeds<G> : never
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The optional seed overrides for a composition's whole `states` tuple, keyed by
|
|
67
|
+
* member name and typed to each member's value. Every key is optional (seeds are
|
|
68
|
+
* overrides over the authored initial). Tuple entries that aren't state groups
|
|
69
|
+
* contribute no keys, so a partially-grouped `states` still types its groups.
|
|
70
|
+
* Powers `seedScene` — a typo'd key or mistyped value is a compile error.
|
|
71
|
+
*/
|
|
72
|
+
export type SeedsOf<St extends ReadonlyArray<unknown>> = Partial<UnionToIntersection<EntrySeeds<St[number]>>>
|
|
73
|
+
|
|
54
74
|
/** Compose atomic States into a group provided (and addressed) as a unit. */
|
|
55
75
|
export const make = <const Members extends ReadonlyArray<AnyState>>(
|
|
56
76
|
...members: Members & NoDuplicateNames<Members>
|