@playfast/reform 0.0.8 → 0.0.10
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/calc/asyncCalc.invalidate.test.ts +166 -0
- package/src/calc/asyncCalc.ts +44 -0
- package/src/calc/queryState.ts +52 -0
- 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 +39 -3
- package/src/internal/capture.ts +8 -0
- package/src/internal/errors.ts +0 -12
- package/src/internal/queryDriver.ts +209 -71
- package/src/internal/queryEvents.ts +34 -0
- package/src/internal/queryStore.ts +36 -0
- package/src/runtime/loop.ts +14 -7
- package/src/runtime/queries.ts +53 -0
- 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.10",
|
|
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": [
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
|
|
3
|
+
import { AsyncCalc, Engine, Queries, QueryStore, State, StateGroup } from '../index'
|
|
4
|
+
|
|
5
|
+
// The data-oriented half of AsyncCalc: `invalidate` flips `isStale` only (no
|
|
6
|
+
// fetch), `refetch` forces a run, a stale query with active readers (or a fresh
|
|
7
|
+
// subscriber) refetches, and `persist` hydrates + writes through an optional
|
|
8
|
+
// QueryStore. `isStale` is read off the `Queries` registry handle.
|
|
9
|
+
|
|
10
|
+
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
11
|
+
|
|
12
|
+
const isStale = (name: string) =>
|
|
13
|
+
Effect.map(Queries, (q) => q.byName.get(name)?.snapshot().isStale ?? false)
|
|
14
|
+
|
|
15
|
+
// An in-memory QueryStore, so persistence is exercised with no DOM.
|
|
16
|
+
const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
17
|
+
const map = new Map<string, unknown>(Object.entries(seed))
|
|
18
|
+
const layer = Layer.succeed(QueryStore, {
|
|
19
|
+
get: (key) => Effect.sync(() => Option.fromNullable(map.has(key) ? map.get(key) : undefined)),
|
|
20
|
+
set: (key, value) => Effect.sync(() => void map.set(key, value)),
|
|
21
|
+
remove: (key) => Effect.sync(() => void map.delete(key)),
|
|
22
|
+
})
|
|
23
|
+
return { map, layer }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
it.live('invalidate marks stale without fetching; a fresh subscriber then refetches', () => {
|
|
27
|
+
class Count extends State.make('count', S.Number) {}
|
|
28
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
29
|
+
const runs = { n: 0 }
|
|
30
|
+
class Q extends AsyncCalc.make('Q', {
|
|
31
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
32
|
+
output: S.Number,
|
|
33
|
+
alwaysOn: true,
|
|
34
|
+
}) {}
|
|
35
|
+
const QLive = AsyncCalc.live(Q, {
|
|
36
|
+
query: ({ count }) =>
|
|
37
|
+
Effect.sync(() => {
|
|
38
|
+
runs.n += 1
|
|
39
|
+
return count
|
|
40
|
+
}),
|
|
41
|
+
})
|
|
42
|
+
const TestLayer = QLive.pipe(
|
|
43
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
44
|
+
Layer.provideMerge(Engine),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return Effect.gen(function* () {
|
|
48
|
+
const store = yield* Q.store
|
|
49
|
+
yield* tick()
|
|
50
|
+
expect(runs.n).toBe(1)
|
|
51
|
+
|
|
52
|
+
// No active readers: invalidate flips the flag but fetches nothing.
|
|
53
|
+
yield* AsyncCalc.invalidate(Q)
|
|
54
|
+
yield* tick()
|
|
55
|
+
expect(yield* isStale('Q')).toBe(true)
|
|
56
|
+
expect(runs.n).toBe(1)
|
|
57
|
+
|
|
58
|
+
// A reader arriving over a stale query (the resubscribe/mount analog) refetches.
|
|
59
|
+
store.subscribe(() => {})
|
|
60
|
+
yield* tick()
|
|
61
|
+
expect(runs.n).toBe(2)
|
|
62
|
+
expect(yield* isStale('Q')).toBe(false)
|
|
63
|
+
}).pipe(Effect.provide(TestLayer))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it.live('invalidate with an active reader auto-refetches immediately, keeping the value', () => {
|
|
67
|
+
class Count extends State.make('count', S.Number) {}
|
|
68
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
69
|
+
const runs = { n: 0 }
|
|
70
|
+
class Q extends AsyncCalc.make('Q', {
|
|
71
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
72
|
+
output: S.Number,
|
|
73
|
+
alwaysOn: true,
|
|
74
|
+
}) {}
|
|
75
|
+
const QLive = AsyncCalc.live(Q, {
|
|
76
|
+
query: ({ count }) =>
|
|
77
|
+
Effect.sync(() => {
|
|
78
|
+
runs.n += 1
|
|
79
|
+
return count
|
|
80
|
+
}).pipe(Effect.delay(Duration.millis(20))),
|
|
81
|
+
})
|
|
82
|
+
const TestLayer = QLive.pipe(
|
|
83
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 7 })),
|
|
84
|
+
Layer.provideMerge(Engine),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return Effect.gen(function* () {
|
|
88
|
+
const store = yield* Q.store
|
|
89
|
+
store.subscribe(() => {}) // active reader
|
|
90
|
+
yield* tick(40)
|
|
91
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
92
|
+
expect(runs.n).toBe(1)
|
|
93
|
+
|
|
94
|
+
// Stale + active ⇒ refetch now; the last value stays visible (SWR).
|
|
95
|
+
yield* AsyncCalc.invalidate(Q)
|
|
96
|
+
yield* tick(5)
|
|
97
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
|
|
98
|
+
yield* tick(40)
|
|
99
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
100
|
+
expect(runs.n).toBe(2)
|
|
101
|
+
}).pipe(Effect.provide(TestLayer))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it.live('refetch forces a run regardless of staleness', () => {
|
|
105
|
+
class Count extends State.make('count', S.Number) {}
|
|
106
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
107
|
+
const runs = { n: 0 }
|
|
108
|
+
class Q extends AsyncCalc.make('Q', {
|
|
109
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
110
|
+
output: S.Number,
|
|
111
|
+
alwaysOn: true,
|
|
112
|
+
}) {}
|
|
113
|
+
const QLive = AsyncCalc.live(Q, {
|
|
114
|
+
query: ({ count }) =>
|
|
115
|
+
Effect.sync(() => {
|
|
116
|
+
runs.n += 1
|
|
117
|
+
return count
|
|
118
|
+
}),
|
|
119
|
+
})
|
|
120
|
+
const TestLayer = QLive.pipe(
|
|
121
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 3 })),
|
|
122
|
+
Layer.provideMerge(Engine),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return Effect.gen(function* () {
|
|
126
|
+
yield* tick()
|
|
127
|
+
expect(runs.n).toBe(1)
|
|
128
|
+
expect(yield* isStale('Q')).toBe(false)
|
|
129
|
+
|
|
130
|
+
// Not stale, no key movement — refetch still runs.
|
|
131
|
+
yield* AsyncCalc.refetch(Q)
|
|
132
|
+
yield* tick()
|
|
133
|
+
expect(runs.n).toBe(2)
|
|
134
|
+
}).pipe(Effect.provide(TestLayer))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it.live('persist: hydrates from the QueryStore (stale) then writes the settled value through', () => {
|
|
138
|
+
class Count extends State.make('count', S.Number) {}
|
|
139
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
140
|
+
const store = fakeQueryStore({ P: 99 }) // a previously-persisted value
|
|
141
|
+
class Q extends AsyncCalc.make('Q', {
|
|
142
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
143
|
+
output: S.Number,
|
|
144
|
+
alwaysOn: true,
|
|
145
|
+
}) {}
|
|
146
|
+
const QLive = AsyncCalc.live(Q, {
|
|
147
|
+
query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(25))),
|
|
148
|
+
persist: { key: 'P' },
|
|
149
|
+
})
|
|
150
|
+
const TestLayer = QLive.pipe(
|
|
151
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
|
|
152
|
+
Layer.provideMerge(store.layer),
|
|
153
|
+
Layer.provideMerge(Engine),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return Effect.gen(function* () {
|
|
157
|
+
const view = yield* Q.store
|
|
158
|
+
// The persisted value shows instantly, marked refetching (stale-while-revalidate).
|
|
159
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
160
|
+
|
|
161
|
+
// The fresh fetch lands and is written through to the store.
|
|
162
|
+
yield* tick(45)
|
|
163
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
164
|
+
expect(store.map.get('P')).toBe(10)
|
|
165
|
+
}).pipe(Effect.provide(TestLayer))
|
|
166
|
+
})
|
package/src/calc/asyncCalc.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { type Store } from '../internal/store'
|
|
|
18
18
|
import { readTracked } from '../internal/track'
|
|
19
19
|
import * as Reducer from '../reducer/reducer'
|
|
20
20
|
import { Reducers } from '../runtime/loop'
|
|
21
|
+
import { Queries } from '../runtime/queries'
|
|
21
22
|
import * as State from '../state/state'
|
|
22
23
|
import { type AnySource } from '../state/token'
|
|
23
24
|
import { type AsyncData, narrowStore } from './asyncData'
|
|
@@ -97,6 +98,15 @@ export type AsyncCalcLive<
|
|
|
97
98
|
* `Calc`'s `reuse`; opt-in (one O(result) walk per settle).
|
|
98
99
|
*/
|
|
99
100
|
readonly reuse?: boolean
|
|
101
|
+
/**
|
|
102
|
+
* Persist the `Success` value through an optional host `QueryStore` (e.g.
|
|
103
|
+
* `@playfast/reform-query-browser`'s localStorage layer): hydrate it on build
|
|
104
|
+
* so the value shows instantly (marked stale, so it still refetches) and write
|
|
105
|
+
* through on each settle. `true` keys it by the calc name; pass `{ key }` to
|
|
106
|
+
* override. Values (de)serialize through the calc's `output` schema. No host
|
|
107
|
+
* `QueryStore` in context ⇒ inert (no hard requirement added).
|
|
108
|
+
*/
|
|
109
|
+
readonly persist?: boolean | { readonly key?: string }
|
|
100
110
|
} & (Gated extends true
|
|
101
111
|
? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
|
|
102
112
|
: { readonly disabled?: never })
|
|
@@ -214,8 +224,20 @@ export function live<
|
|
|
214
224
|
readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
|
|
215
225
|
readonly coalesce?: 'switch' | 'trailing'
|
|
216
226
|
readonly reuse?: boolean
|
|
227
|
+
readonly persist?: boolean | { readonly key?: string }
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
// `persist: true` keys by the calc name; `{ key }` overrides. The driver
|
|
231
|
+
// (de)serializes through the calc's own `output` schema.
|
|
232
|
+
const persist =
|
|
233
|
+
cfg.persist === undefined || cfg.persist === false
|
|
234
|
+
? undefined
|
|
235
|
+
: {
|
|
236
|
+
key:
|
|
237
|
+
cfg.persist === true ? calc.manifest.name : cfg.persist.key ?? calc.manifest.name,
|
|
238
|
+
schema: calc.manifest.output,
|
|
239
|
+
}
|
|
240
|
+
|
|
219
241
|
// The hidden revision store, read requirement-free (`serviceOption`): the
|
|
220
242
|
// assembly below always provides it alongside this driver, and feeding it
|
|
221
243
|
// through `extraKey` (not `wireSources`) keeps it out of the snapshot —
|
|
@@ -236,6 +258,7 @@ export function live<
|
|
|
236
258
|
disabled: cfg.disabled,
|
|
237
259
|
coalesce: cfg.coalesce,
|
|
238
260
|
reuse: cfg.reuse,
|
|
261
|
+
persist,
|
|
239
262
|
extraKey:
|
|
240
263
|
revision === undefined
|
|
241
264
|
? undefined
|
|
@@ -265,3 +288,24 @@ export function live<
|
|
|
265
288
|
Layer.provide(State.live(revisionState, revisionZero)),
|
|
266
289
|
)
|
|
267
290
|
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Mark a query's value stale (`isStale := true`) — without fetching. A stale
|
|
294
|
+
* query with active readers (a mounted view) refetches; an unread one refetches
|
|
295
|
+
* when next subscribed. The imperative counterpart of `invalidateOn`, and what a
|
|
296
|
+
* focus/online provider layer calls. A no-op if the calc isn't live; resolves the
|
|
297
|
+
* `Queries` registry (part of `Engine`).
|
|
298
|
+
*/
|
|
299
|
+
export const invalidate = (calc: { readonly name: string }): Effect.Effect<void, never, Queries> =>
|
|
300
|
+
Effect.flatMap(Queries, (queries) =>
|
|
301
|
+
Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Force an immediate refetch of the current key, bypassing the no-op-key guard
|
|
306
|
+
* and independent of `isStale`. A no-op if the calc isn't live or is disabled.
|
|
307
|
+
*/
|
|
308
|
+
export const refetch = (calc: { readonly name: string }): Effect.Effect<void, never, Queries> =>
|
|
309
|
+
Effect.flatMap(Queries, (queries) =>
|
|
310
|
+
Effect.sync(() => queries.byName.get(calc.name)?.refetch()),
|
|
311
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Option } from 'effect'
|
|
2
|
+
import { type AnyAsyncData, AsyncData } from './asyncData'
|
|
3
|
+
|
|
4
|
+
// `QueryState` is the data-oriented value an `AsyncCalc` is driven by: the four
|
|
5
|
+
// axes of a query, stored *flat and independent* so each can move on its own.
|
|
6
|
+
// `data`/`error` survive a refetch (stale-while-revalidate for free); `isFetching`
|
|
7
|
+
// (activity) and `isStale` (freshness) are orthogonal to both — which is exactly
|
|
8
|
+
// what lets `invalidate` (flip `isStale`) and `refetch` (drive `isFetching`) be
|
|
9
|
+
// separate operations. The matchable `AsyncData` union is *derived* from this
|
|
10
|
+
// (`toAsyncData`), so view code and `RemoteState` keep their arm dispatch.
|
|
11
|
+
|
|
12
|
+
export interface QueryState<A, E> {
|
|
13
|
+
/** Last resolved value — kept across a refetch. */
|
|
14
|
+
readonly data: Option.Option<A>
|
|
15
|
+
/** Last failure — kept across a refetch. */
|
|
16
|
+
readonly error: Option.Option<E>
|
|
17
|
+
/** Activity axis: a run is in flight. */
|
|
18
|
+
readonly isFetching: boolean
|
|
19
|
+
/** Freshness axis: the value has been marked invalid (drives auto-refetch). */
|
|
20
|
+
readonly isStale: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The empty cell. `fetching` seeds the first-kick `Loading` for an enabled query. */
|
|
24
|
+
export const empty = (fetching: boolean): QueryState<never, never> => ({
|
|
25
|
+
data: Option.none(),
|
|
26
|
+
error: Option.none(),
|
|
27
|
+
isFetching: fetching,
|
|
28
|
+
isStale: false,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Project the flat state onto the public `AsyncData` union (the view contract) —
|
|
33
|
+
* the single `Match`-able view of a query, so call sites keep `Match.tag`-ing the
|
|
34
|
+
* arms. `error` wins over `data` so a failed refetch surfaces the `Error` arm,
|
|
35
|
+
* matching the pre-`QueryState` driver; `isFetching` becomes the arm's
|
|
36
|
+
* `refetching` flag. The data-less case is `Loading` while fetching (or for a
|
|
37
|
+
* non-gated query, which always fetches) and `Idle` only for a switched-off
|
|
38
|
+
* gateable query.
|
|
39
|
+
*/
|
|
40
|
+
export const toAsyncData = <A, E>(s: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> =>
|
|
41
|
+
Option.match(s.error, {
|
|
42
|
+
onSome: (error) => AsyncData.error(error, s.isFetching),
|
|
43
|
+
onNone: () =>
|
|
44
|
+
Option.match(s.data, {
|
|
45
|
+
onSome: (value) => AsyncData.success(value, s.isFetching),
|
|
46
|
+
onNone: () => (s.isFetching || !gated ? AsyncData.loading : AsyncData.idle),
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** `Loading` in the React-Query sense: fetching with nothing to show yet. */
|
|
51
|
+
export const isLoading = <A, E>(s: QueryState<A, E>): boolean =>
|
|
52
|
+
s.isFetching && Option.isNone(s.data)
|
|
@@ -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
|
+
}
|