@playfast/reform 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ State, events, reducers, derived values, async & remote data, and compositions
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
reform is an application framework built on [Effect](https://effect.website). You describe your app as **definitions** — state, events, reducers, calculations, compositions — and provide their behavior **separately** as Effect layers. The core renders nothing: a host package ([`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react), [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native)) turns a closed
|
|
16
|
+
reform is an application framework built on [Effect](https://effect.website). You describe your app as **definitions** — state, events, reducers, calculations, compositions — and provide their behavior **separately** as Effect layers. The core renders nothing: a host package ([`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react), [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native)) turns a closed _scene_ into a live tree, and [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) drives that same scene headlessly in tests. One model, three consumers, no seam between them.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
@@ -62,24 +62,24 @@ class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {
|
|
|
62
62
|
const BumpLive = Reducer.live(Bump, (n, e) => n + e.by)
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
A `State` lives in a plain reactive store; reducers only
|
|
65
|
+
A `State` lives in a plain reactive store; reducers only _return_ values, so by construction they are the **only** writers. Reads return the current snapshot through Effect.
|
|
66
66
|
|
|
67
67
|
### The primitives at a glance
|
|
68
68
|
|
|
69
|
-
| primitive
|
|
70
|
-
|
|
|
71
|
-
| [`State` / `StateGroup` / `StateFamily`](#state) | `State.make(name, schema, opts?)`
|
|
72
|
-
| [`Event` / `EventGroup`](#event)
|
|
73
|
-
| [`Reducer`](#reducer)
|
|
74
|
-
| [`Calc` / `CalcFamily`](#calc)
|
|
75
|
-
| [`AsyncCalc`](#asynccalc)
|
|
76
|
-
| [`RemoteState`](#remotestate)
|
|
77
|
-
| [`Boundary`](#boundary)
|
|
78
|
-
| [`Procedure`](#procedure)
|
|
79
|
-
| [`Channel`](#procedure)
|
|
80
|
-
| [`Composition`](#composition)
|
|
81
|
-
| [`ui` / `slot`](#composition)
|
|
82
|
-
| [`Feature`](#feature)
|
|
69
|
+
| primitive | definition | implementation |
|
|
70
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------ | ---------------------------------- |
|
|
71
|
+
| [`State` / `StateGroup` / `StateFamily`](#state) | `State.make(name, schema, opts?)` | `.live(initial)` |
|
|
72
|
+
| [`Event` / `EventGroup`](#event) | `Event.make(name, schema)` | — (pure data) |
|
|
73
|
+
| [`Reducer`](#reducer) | `Reducer.make(name, { states / family, events })` | `.live(fold)` |
|
|
74
|
+
| [`Calc` / `CalcFamily`](#calc) | `Calc.make(name, { inputs, output })` | `.live(fn)` |
|
|
75
|
+
| [`AsyncCalc`](#asynccalc) | `AsyncCalc.make(name, { inputs, output, error?, alwaysOn? })` | `.live({ query, … })` |
|
|
76
|
+
| [`RemoteState`](#remotestate) | `RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents })` | `.live({ query, send, apply, … })` |
|
|
77
|
+
| [`Boundary`](#boundary) | `Boundary.make(name, { over })` | `.live({ once? })` |
|
|
78
|
+
| [`Procedure`](#procedure) | `Procedure.make(name, { events, channel })` | `.live(fn*)` |
|
|
79
|
+
| [`Channel`](#procedure) | `Channel.make(name, { policy })` | `.live()` |
|
|
80
|
+
| [`Composition`](#composition) | `Composition.make(name, manifest)` | `.live(fn*)` |
|
|
81
|
+
| [`ui` / `slot`](#composition) | `ui(name)<C>()` / `slot(name, identity)<Comp>()` | `Ui.make` / `provide` |
|
|
82
|
+
| [`Feature`](#feature) | `Feature.make(name, config)` | (eager `module` / lazy `load`) |
|
|
83
83
|
|
|
84
84
|
## The model
|
|
85
85
|
|
|
@@ -98,6 +98,7 @@ procedure ──► Bus (Normal)┘ │
|
|
|
98
98
|
---
|
|
99
99
|
|
|
100
100
|
<a name="state"></a>
|
|
101
|
+
|
|
101
102
|
## State · StateGroup · StateFamily
|
|
102
103
|
|
|
103
104
|
Reactive stores held outside Effect. Reducers are the sole writers; everything else reads.
|
|
@@ -119,13 +120,13 @@ A bundle of related states provided together. `StateGroup.select(Group, 'name')`
|
|
|
119
120
|
import { State, StateGroup } from '@playfast/reform'
|
|
120
121
|
|
|
121
122
|
class Count extends State.make('count', S.Number) {}
|
|
122
|
-
class Step
|
|
123
|
+
class Step extends State.make('step', S.Number) {}
|
|
123
124
|
class Counter extends StateGroup.make(Count, Step) {}
|
|
124
125
|
|
|
125
126
|
const CounterLive = StateGroup.live(Counter, { count: 0, step: 1 }) // all members required
|
|
126
127
|
|
|
127
128
|
// read inside any Effect / composition:
|
|
128
|
-
const count = yield* StateGroup.select(Counter, 'count')
|
|
129
|
+
const count = yield * StateGroup.select(Counter, 'count')
|
|
129
130
|
```
|
|
130
131
|
|
|
131
132
|
### `StateFamily`
|
|
@@ -149,13 +150,14 @@ const item = yield* StateFamily.read(Items, id)
|
|
|
149
150
|
---
|
|
150
151
|
|
|
151
152
|
<a name="event"></a>
|
|
153
|
+
|
|
152
154
|
## Event · EventGroup
|
|
153
155
|
|
|
154
156
|
Events are pure tagged data — definitions only, no `.live`.
|
|
155
157
|
|
|
156
158
|
```ts
|
|
157
|
-
Event.make(name, schema)
|
|
158
|
-
EventGroup.make(...events)
|
|
159
|
+
Event.make(name, schema) // → EventClass; EventOf<N, P> = { _tag: N } & P
|
|
160
|
+
EventGroup.make(...events) // → bundle, used in Reducer/Composition manifests
|
|
159
161
|
|
|
160
162
|
class LoadedTodos extends Event.make('LoadedTodos', S.Struct({ todos: S.Array(Todo) })) {}
|
|
161
163
|
```
|
|
@@ -164,11 +166,11 @@ class LoadedTodos extends Event.make('LoadedTodos', S.Struct({ todos: S.Array(To
|
|
|
164
166
|
|
|
165
167
|
```ts
|
|
166
168
|
// From a UI view — High priority, synchronous:
|
|
167
|
-
const toggle = yield* Event.trigger(ToggledTodo)
|
|
169
|
+
const toggle = yield * Event.trigger(ToggledTodo) // toggle: (payload) => void
|
|
168
170
|
// ...later: <input onChange={() => toggle({ id })} />
|
|
169
171
|
|
|
170
172
|
// From a procedure — Normal priority:
|
|
171
|
-
yield* Event.dispatch(TodoUpserted, { todo })
|
|
173
|
+
yield * Event.dispatch(TodoUpserted, { todo })
|
|
172
174
|
```
|
|
173
175
|
|
|
174
176
|
`Event.trigger` returns a plain callback (`Trigger<P>`) you hand to the view; `Event.dispatch` returns an `Effect` you yield inside logic.
|
|
@@ -176,6 +178,7 @@ yield* Event.dispatch(TodoUpserted, { todo })
|
|
|
176
178
|
---
|
|
177
179
|
|
|
178
180
|
<a name="reducer"></a>
|
|
181
|
+
|
|
179
182
|
## Reducer
|
|
180
183
|
|
|
181
184
|
The only writers of state. A fold is a **pure, synchronous** `(value, event) => value` (async values throw at startup).
|
|
@@ -195,9 +198,13 @@ const FeedReducerLive = Reducer.live(FeedReducer, (feed, event) =>
|
|
|
195
198
|
Match.value(event).pipe(
|
|
196
199
|
Match.tags({
|
|
197
200
|
StartedLoading: (): Feed => ({ _tag: 'Loading' }),
|
|
198
|
-
LoadedTodos:
|
|
199
|
-
FailedTodos:
|
|
200
|
-
TodoRemoved:
|
|
201
|
+
LoadedTodos: ({ todos }): Feed => ({ _tag: 'Ok', todos }),
|
|
202
|
+
FailedTodos: ({ message }): Feed => ({ _tag: 'Error', message }),
|
|
203
|
+
TodoRemoved: ({ id }) =>
|
|
204
|
+
onOk(
|
|
205
|
+
feed,
|
|
206
|
+
Array.filter((t) => t.id !== id),
|
|
207
|
+
),
|
|
201
208
|
}),
|
|
202
209
|
Match.exhaustive,
|
|
203
210
|
),
|
|
@@ -209,6 +216,7 @@ An event outside the reducer's declared `events` never reaches the fold; the dra
|
|
|
209
216
|
---
|
|
210
217
|
|
|
211
218
|
<a name="calc"></a>
|
|
219
|
+
|
|
212
220
|
## Calc · CalcFamily
|
|
213
221
|
|
|
214
222
|
Synchronous derived values — memoized projections over one or more sources.
|
|
@@ -231,15 +239,26 @@ const positive = yield* IsPositive // read the memoized value
|
|
|
231
239
|
**`CalcFamily`** parameterizes a calc by key, one memoized store per key over shared inputs — each member notifies only its own subscribers:
|
|
232
240
|
|
|
233
241
|
```ts
|
|
234
|
-
class GroupView extends CalcFamily.make('GroupView', {
|
|
235
|
-
|
|
242
|
+
class GroupView extends CalcFamily.make('GroupView', {
|
|
243
|
+
key: GroupId,
|
|
244
|
+
inputs: [Board],
|
|
245
|
+
output: GroupSchema,
|
|
246
|
+
}) {}
|
|
247
|
+
const GroupViewLive = CalcFamily.live(
|
|
248
|
+
GroupView,
|
|
249
|
+
(id) =>
|
|
250
|
+
({ Board }) =>
|
|
251
|
+
project(Board, id),
|
|
252
|
+
{ evictWhenUnused: true },
|
|
253
|
+
)
|
|
236
254
|
|
|
237
|
-
const view = yield* CalcFamily.read(GroupView, groupId)
|
|
255
|
+
const view = yield * CalcFamily.read(GroupView, groupId)
|
|
238
256
|
```
|
|
239
257
|
|
|
240
258
|
---
|
|
241
259
|
|
|
242
260
|
<a name="asynccalc"></a>
|
|
261
|
+
|
|
243
262
|
## AsyncCalc & AsyncData
|
|
244
263
|
|
|
245
264
|
Server reads with a stale-while-revalidate lifecycle. The query re-runs reactively from its inputs.
|
|
@@ -259,12 +278,12 @@ AsyncCalc.live(AsyncCalc, {
|
|
|
259
278
|
- `error` omitted ⇒ the query is infallible (no `Error` arm). `alwaysOn: true` ⇒ no `Idle` arm and `disabled` is rejected.
|
|
260
279
|
- Reading `yield* MyAsyncCalc` yields an **`AsyncData<A, E, Gated>`**:
|
|
261
280
|
|
|
262
|
-
| arm
|
|
263
|
-
|
|
|
264
|
-
| `AsyncIdle`
|
|
265
|
-
| `AsyncLoading` | `'Loading'` | —
|
|
281
|
+
| arm | `_tag` | fields | when |
|
|
282
|
+
| -------------- | ----------- | --------------------- | ------------------------------------------------ |
|
|
283
|
+
| `AsyncIdle` | `'Idle'` | — | gated query is `disabled` |
|
|
284
|
+
| `AsyncLoading` | `'Loading'` | — | first fetch, no value yet |
|
|
266
285
|
| `AsyncSuccess` | `'Success'` | `value`, `refetching` | succeeded (`refetching: true` while re-fetching) |
|
|
267
|
-
| `AsyncError`
|
|
286
|
+
| `AsyncError` | `'Error'` | `error`, `refetching` | failed |
|
|
268
287
|
|
|
269
288
|
```ts
|
|
270
289
|
class Doubled extends AsyncCalc.make('Doubled', {
|
|
@@ -276,7 +295,7 @@ const DoubledLive = AsyncCalc.live(Doubled, {
|
|
|
276
295
|
query: ({ count }) => Effect.succeed(count * 2),
|
|
277
296
|
})
|
|
278
297
|
|
|
279
|
-
const data = yield* Doubled
|
|
298
|
+
const data = yield * Doubled
|
|
280
299
|
Match.value(data).pipe(
|
|
281
300
|
Match.tag('Loading', () => spinner),
|
|
282
301
|
Match.tag('Success', ({ value }) => render(value)),
|
|
@@ -287,9 +306,10 @@ Match.value(data).pipe(
|
|
|
287
306
|
---
|
|
288
307
|
|
|
289
308
|
<a name="remotestate"></a>
|
|
309
|
+
|
|
290
310
|
## RemoteState
|
|
291
311
|
|
|
292
|
-
Server-owned state with optimistic mutations, fused into one primitive. **Remote state is derived-only:** the only writer is the server, the only write surface is dispatching a declared
|
|
312
|
+
Server-owned state with optimistic mutations, fused into one primitive. **Remote state is derived-only:** the only writer is the server, the only write surface is dispatching a declared _intent_, and the visible value is `pending.reduce(apply, serverTruth)`.
|
|
293
313
|
|
|
294
314
|
```ts
|
|
295
315
|
RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents }) // intents: Event definitions
|
|
@@ -318,23 +338,25 @@ class Board extends RemoteState.make('Board', {
|
|
|
318
338
|
}) {}
|
|
319
339
|
|
|
320
340
|
const BoardLive = RemoteState.live(Board, {
|
|
321
|
-
query:
|
|
322
|
-
send:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
341
|
+
query: ({ route }) => loadBoard(route.boardId),
|
|
342
|
+
send: (intent) =>
|
|
343
|
+
Match.value(intent).pipe(
|
|
344
|
+
Match.tag('ItemAddIntent', ({ groupId, name }) => client.AddItem({ groupId, name })),
|
|
345
|
+
Match.tag('ItemRenameIntent', ({ itemId, name }) => client.RenameItem({ id: itemId, name })),
|
|
346
|
+
Match.exhaustive,
|
|
347
|
+
),
|
|
348
|
+
apply: (snapshot, intent) => applyIntent(snapshot, intent), // idempotent
|
|
328
349
|
invalidateOn: [BoardChanged],
|
|
329
350
|
coalesce: 'trailing',
|
|
330
351
|
})
|
|
331
352
|
```
|
|
332
353
|
|
|
333
|
-
**Semantics.** A dispatched intent appends to the queue and appears in the overlay in the same flush. On send failure the intent settles immediately and `Board.Failed` fires — there is **no rollback machinery**, the derivation just converges. The **generation rule** guarantees an optimistic change never flickers out between the RPC confirming and the refetch landing: an intent acked at query generation
|
|
354
|
+
**Semantics.** A dispatched intent appends to the queue and appears in the overlay in the same flush. On send failure the intent settles immediately and `Board.Failed` fires — there is **no rollback machinery**, the derivation just converges. The **generation rule** guarantees an optimistic change never flickers out between the RPC confirming and the refetch landing: an intent acked at query generation _g_ is settled only by a later-generation `Success` run.
|
|
334
355
|
|
|
335
356
|
---
|
|
336
357
|
|
|
337
358
|
<a name="boundary"></a>
|
|
359
|
+
|
|
338
360
|
## Boundary
|
|
339
361
|
|
|
340
362
|
Merges the lifecycle arms of several async sources into one first-load signal, so a subtree shows a single fallback instead of per-query spinners.
|
|
@@ -360,6 +382,7 @@ const boot = yield* BootBoundary // BoundaryState
|
|
|
360
382
|
---
|
|
361
383
|
|
|
362
384
|
<a name="procedure"></a>
|
|
385
|
+
|
|
363
386
|
## Procedure & Channel
|
|
364
387
|
|
|
365
388
|
Procedures are side-effecting reactions to events, running on forked fibers. A **channel** is the named concurrency lane they run on.
|
|
@@ -377,14 +400,17 @@ The generator body receives the matched event, can read services from context, a
|
|
|
377
400
|
```ts
|
|
378
401
|
class ActionsChannel extends Channel.make('Actions', { policy: { _tag: 'merge' } }) {}
|
|
379
402
|
|
|
380
|
-
class CreateTodo extends Procedure.make('CreateTodo', {
|
|
403
|
+
class CreateTodo extends Procedure.make('CreateTodo', {
|
|
404
|
+
events: [SubmittedNewTodo],
|
|
405
|
+
channel: ActionsChannel,
|
|
406
|
+
}) {}
|
|
381
407
|
const CreateTodoLive = Procedure.live(CreateTodo, function* (event) {
|
|
382
408
|
const client = yield* TodosClient
|
|
383
409
|
const result = yield* Effect.either(client.AddTodo({ text: event.text }))
|
|
384
410
|
yield* Match.value(result).pipe(
|
|
385
411
|
Match.tags({
|
|
386
412
|
Right: ({ right }) => Event.dispatch(TodoUpserted, { todo: right }),
|
|
387
|
-
Left:
|
|
413
|
+
Left: ({ left }) => Event.dispatch(FailedTodos, { message: String(left) }),
|
|
388
414
|
}),
|
|
389
415
|
Match.exhaustive,
|
|
390
416
|
)
|
|
@@ -396,6 +422,7 @@ Policies: `merge` (unbounded concurrency), `latest` (new event cancels the in-fl
|
|
|
396
422
|
---
|
|
397
423
|
|
|
398
424
|
<a name="composition"></a>
|
|
425
|
+
|
|
399
426
|
## Composition · ui · slot · provide
|
|
400
427
|
|
|
401
428
|
A **composition** is a unit of logic that reads state and renders a typed UI contract. The contract (`ui`) and its presentation are authored separately, so logic stays renderer-neutral.
|
|
@@ -426,12 +453,13 @@ const CounterView = provide(CounterUi, Ui.make(CounterUi, ({ count }, _slots, {
|
|
|
426
453
|
|
|
427
454
|
- **`Composition.make(name, manifest)`** — manifest declares `ui` plus the `states` / `calcs` / `events` / `slots` it touches (all reflectable).
|
|
428
455
|
- **`Composition.live(comp, fn*)`** — the generator reads sources and returns a `Node` (the view applied to props/events).
|
|
429
|
-
- **`slot(name)<Comp>()`** — a hole a parent composition declares (`slots: { body: BodySlot }`) and fills with `provide(BodySlot, ChildComposition)` (or a `Feature`). Children render through the host.
|
|
456
|
+
- **`slot(name, identity)<Comp>()`** — a hole a parent composition declares (`slots: { body: BodySlot }`) and fills with `provide(BodySlot, ChildComposition)` (or a `Feature`). Declare `identity` as a named module-level unique symbol (`const BodySlotIdentity: unique symbol = Symbol('BodySlot')`); `Symbol.for(...)` and inline `Symbol(...)` arguments are forbidden. Reform generates a separate runtime key for every slot definition, while `name` remains a display label, so independently authored same-name slots remain distinct. Children render through the host.
|
|
430
457
|
- **`provide(...)`** — binds a UI presentation to a contract, or fills a slot with a composition / feature. Returns a `Layer`.
|
|
431
458
|
|
|
432
459
|
---
|
|
433
460
|
|
|
434
461
|
<a name="feature"></a>
|
|
462
|
+
|
|
435
463
|
## Feature
|
|
436
464
|
|
|
437
465
|
The lazy/eager code-split unit. Definitions stay eager and reflectable; the heavy implementation can load on demand as an **Effect** (never a bare Promise), so code-splitting composes with the rest of the layer graph.
|
|
@@ -449,22 +477,36 @@ class Counter extends Feature.make('counter', {
|
|
|
449
477
|
// lazy: defer the module behind an import
|
|
450
478
|
class Reports extends Feature.make('reports', {
|
|
451
479
|
loadingStrategy: 'lazy',
|
|
480
|
+
composition: ReportsComp,
|
|
452
481
|
load: lazyImport(() => import('./reports.module')),
|
|
482
|
+
eager: featureModule([], PlaceholderLive),
|
|
453
483
|
placeholder: { loading: SpinnerComp, failed: RetryComp },
|
|
454
484
|
}) {}
|
|
455
485
|
```
|
|
456
486
|
|
|
457
|
-
A feature
|
|
487
|
+
A feature's `requires` tuple accepts shared State/Calc carriers, StateGroups, and
|
|
488
|
+
ordinary Effect `Context.Tag`s. A lazy feature shares the app's engine but gets
|
|
489
|
+
its own scope — `mountFeature(binding, parentContext)` loads it, builds its layer
|
|
490
|
+
against the immediate parent, dispatches its boot events, and disposes everything
|
|
491
|
+
when the scope closes. Fill a slot with one via `provide(SomeSlot, Reports)`;
|
|
492
|
+
`provide` merges its optional eager placeholder module automatically while the
|
|
493
|
+
host paints the placeholders and loads the main module.
|
|
494
|
+
|
|
495
|
+
Default Features retain their binding identity too. Their module is merged by
|
|
496
|
+
`provide`, their composition renders synchronously from the parent runtime, and
|
|
497
|
+
their boot events run once for each keyed activation.
|
|
458
498
|
|
|
459
499
|
---
|
|
460
500
|
|
|
461
501
|
<a name="scene"></a>
|
|
502
|
+
|
|
462
503
|
## Scene
|
|
463
504
|
|
|
464
505
|
A scene bundles a root composition with the closed wiring that runs it — the single handle the React host, React Native host, and proofs all consume.
|
|
465
506
|
|
|
466
507
|
```ts
|
|
467
508
|
scene(composition, { provide: [...layers], boot?: [...events] }) // → Scene
|
|
509
|
+
featureScene(eagerFeature, { provide: closedEnvironment }) // → Scene
|
|
468
510
|
seedScene(base, seeds) // tooling: override seed values
|
|
469
511
|
isScene(value) // reflection guard
|
|
470
512
|
|
|
@@ -476,9 +518,14 @@ const AppScene = scene(AppRoot, {
|
|
|
476
518
|
|
|
477
519
|
`provide` is the list of layers that close the app (they must resolve to `MountedServices` — every composition's render service plus the `Bus`). Hand the scene to [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) to mount or to [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) to assert. `seedScene` overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
|
|
478
520
|
|
|
521
|
+
`featureScene` is the typed root helper for a default/eager Feature. Its `provide`
|
|
522
|
+
layer closes the engine plus the Feature's declared requirements; lazy Features
|
|
523
|
+
are rejected because an application root must be immediately available.
|
|
524
|
+
|
|
479
525
|
---
|
|
480
526
|
|
|
481
527
|
<a name="engine"></a>
|
|
528
|
+
|
|
482
529
|
## Engine & runtime surface
|
|
483
530
|
|
|
484
531
|
`Engine` is the layer that ties a runtime together: it provides the `Bus` (one `PubSub`), the `Reducers` / `Channels` / `Procedures` registries, and forks the single drain loop. Merge it at the root of your app's layers:
|
|
@@ -547,15 +594,15 @@ Mount it with [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast
|
|
|
547
594
|
|
|
548
595
|
## The reform family
|
|
549
596
|
|
|
550
|
-
| Package
|
|
551
|
-
|
|
|
552
|
-
| **`@playfast/reform`**
|
|
553
|
-
| [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react)
|
|
554
|
-
| [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native)
|
|
555
|
-
| [`@playfast/reform-forms`](https://www.npmjs.com/package/@playfast/reform-forms)
|
|
556
|
-
| [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react)
|
|
557
|
-
| [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof)
|
|
558
|
-
| [`@playfast/reform-eslint-plugin`](https://www.npmjs.com/package/@playfast/reform-eslint-plugin) | Lint rules for reform conventions
|
|
597
|
+
| Package | Role |
|
|
598
|
+
| ------------------------------------------------------------------------------------------------ | ------------------------------------ |
|
|
599
|
+
| **`@playfast/reform`** | Renderer-neutral core (this package) |
|
|
600
|
+
| [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) | React / DOM host |
|
|
601
|
+
| [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native) | React Native host |
|
|
602
|
+
| [`@playfast/reform-forms`](https://www.npmjs.com/package/@playfast/reform-forms) | Headless form state |
|
|
603
|
+
| [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react) | Typed JSX mapping for forms |
|
|
604
|
+
| [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) | Headless testing toolkit |
|
|
605
|
+
| [`@playfast/reform-eslint-plugin`](https://www.npmjs.com/package/@playfast/reform-eslint-plugin) | Lint rules for reform conventions |
|
|
559
606
|
|
|
560
607
|
## License
|
|
561
608
|
|
package/package.json
CHANGED
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform",
|
|
3
|
-
"
|
|
4
|
-
"version": "1.0.1",
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.2.0",
|
|
6
4
|
"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
5
|
"keywords": [
|
|
8
|
-
"reform",
|
|
9
6
|
"effect",
|
|
10
|
-
"state-management",
|
|
11
|
-
"headless",
|
|
12
7
|
"framework",
|
|
13
|
-
"
|
|
8
|
+
"headless",
|
|
9
|
+
"reactive",
|
|
10
|
+
"reform",
|
|
11
|
+
"state-management"
|
|
14
12
|
],
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
15
|
+
},
|
|
15
16
|
"license": "MIT",
|
|
16
17
|
"repository": {
|
|
17
18
|
"type": "git",
|
|
18
19
|
"url": "https://github.com/playfast/reform.git",
|
|
19
20
|
"directory": "packages/reform"
|
|
20
21
|
},
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
24
27
|
"sideEffects": false,
|
|
25
28
|
"exports": {
|
|
26
29
|
"./package.json": "./package.json",
|
|
27
30
|
".": "./src/index.ts",
|
|
28
31
|
"./*": "./src/*.ts"
|
|
29
32
|
},
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
34
36
|
"scripts": {
|
|
35
37
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
36
38
|
"check": "tsc --noEmit",
|
|
@@ -50,7 +52,5 @@
|
|
|
50
52
|
"optional": true
|
|
51
53
|
}
|
|
52
54
|
},
|
|
53
|
-
"
|
|
54
|
-
"access": "public"
|
|
55
|
-
}
|
|
55
|
+
"playbook": "./playbook"
|
|
56
56
|
}
|
|
@@ -2,12 +2,6 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
3
|
import { AsyncCalc, Boundary, Calc, Engine, Event, State, StateGroup } from '../index'
|
|
4
4
|
|
|
5
|
-
// Boundary merges several async lifecycles into one Pending/Errored/Ready value
|
|
6
|
-
// — the Suspense-boundary analog, value-level. The behaviours under test: the
|
|
7
|
-
// arm mapping (Loading pends, Idle and SWR-refetching count Ready, Error
|
|
8
|
-
// surfaces), the chained-gate settle-gap pin, the `once` latch, and the
|
|
9
|
-
// notification economy (arm-stable churn wakes nobody).
|
|
10
|
-
|
|
11
5
|
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
12
6
|
|
|
13
7
|
const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
|
|
@@ -37,8 +31,12 @@ it.live('pending while ANY covered query is on its first load; one Ready when al
|
|
|
37
31
|
const TestLayer = Boundary.live(Gate).pipe(
|
|
38
32
|
Layer.provideMerge(
|
|
39
33
|
Layer.merge(
|
|
40
|
-
AsyncCalc.live(Fast, {
|
|
41
|
-
|
|
34
|
+
AsyncCalc.live(Fast, {
|
|
35
|
+
query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('10 millis')),
|
|
36
|
+
}),
|
|
37
|
+
AsyncCalc.live(Slow, {
|
|
38
|
+
query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('40 millis')),
|
|
39
|
+
}),
|
|
42
40
|
),
|
|
43
41
|
),
|
|
44
42
|
Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
|
|
@@ -51,13 +49,13 @@ it.live('pending while ANY covered query is on its first load; one Ready when al
|
|
|
51
49
|
})
|
|
52
50
|
expect(store.get()._tag).toBe('Pending')
|
|
53
51
|
|
|
54
|
-
// The fast query settles first — still Pending (the slow one holds it).
|
|
55
52
|
yield* tick(20)
|
|
56
53
|
expect(store.get()._tag).toBe('Pending')
|
|
57
54
|
|
|
58
|
-
yield* until(
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
yield* until(
|
|
56
|
+
() => store.get()._tag,
|
|
57
|
+
(t) => t === 'Ready',
|
|
58
|
+
)
|
|
61
59
|
expect(observed).toEqual(['Ready'])
|
|
62
60
|
}).pipe(Effect.provide(TestLayer))
|
|
63
61
|
})
|
|
@@ -86,9 +84,10 @@ it.live('a gated query that is OFF counts Ready — the off arm must not hold th
|
|
|
86
84
|
)
|
|
87
85
|
return Effect.gen(function* () {
|
|
88
86
|
const store = yield* Gate.store
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
yield* until(
|
|
88
|
+
() => store.get()._tag,
|
|
89
|
+
(t) => t === 'Ready',
|
|
90
|
+
)
|
|
92
91
|
expect(store.get()._tag).toBe('Ready')
|
|
93
92
|
}).pipe(Effect.provide(TestLayer))
|
|
94
93
|
})
|
|
@@ -114,13 +113,21 @@ it.live('an SWR refetch never re-pends — Success with refetching: true is Read
|
|
|
114
113
|
const seed = yield* StateGroup.select(Inputs, 'seed').store
|
|
115
114
|
const qStore = yield* Q.store
|
|
116
115
|
const store = yield* Gate.store
|
|
117
|
-
yield* until(
|
|
116
|
+
yield* until(
|
|
117
|
+
() => store.get()._tag,
|
|
118
|
+
(t) => t === 'Ready',
|
|
119
|
+
)
|
|
118
120
|
|
|
119
|
-
// Move the input: the query goes Success+refetching (SWR) — Ready holds.
|
|
120
121
|
seed.set(2)
|
|
121
|
-
yield* until(
|
|
122
|
+
yield* until(
|
|
123
|
+
() => qStore.get(),
|
|
124
|
+
(q) => q._tag === 'Success' && q.refetching,
|
|
125
|
+
)
|
|
122
126
|
expect(store.get()._tag).toBe('Ready')
|
|
123
|
-
yield* until(
|
|
127
|
+
yield* until(
|
|
128
|
+
() => qStore.get(),
|
|
129
|
+
(q) => q._tag === 'Success' && !q.refetching,
|
|
130
|
+
)
|
|
124
131
|
expect(store.get()._tag).toBe('Ready')
|
|
125
132
|
}).pipe(Effect.provide(TestLayer))
|
|
126
133
|
})
|
|
@@ -150,28 +157,27 @@ it.live('a first-load failure surfaces as Errored with the failure, and a retry
|
|
|
150
157
|
)
|
|
151
158
|
return Effect.gen(function* () {
|
|
152
159
|
const store = yield* Gate.store
|
|
153
|
-
const errored = yield* until(
|
|
160
|
+
const errored = yield* until(
|
|
161
|
+
() => store.get(),
|
|
162
|
+
(s) => s._tag === 'Errored',
|
|
163
|
+
)
|
|
154
164
|
if (errored._tag === 'Errored') {
|
|
155
165
|
expect(errored.errors).toHaveLength(1)
|
|
156
166
|
const failure = errored.errors[0]
|
|
157
167
|
expect(failure instanceof Boom && failure.message).toBe('server down')
|
|
158
168
|
}
|
|
159
169
|
|
|
160
|
-
// The retry path: the server heals, a listed fact refetches, Ready lands.
|
|
161
170
|
healthy.now = true
|
|
162
171
|
yield* Event.dispatch(Poke, {})
|
|
163
|
-
yield* until(
|
|
172
|
+
yield* until(
|
|
173
|
+
() => store.get()._tag,
|
|
174
|
+
(t) => t === 'Ready',
|
|
175
|
+
)
|
|
164
176
|
expect(store.get()._tag).toBe('Ready')
|
|
165
177
|
}).pipe(Effect.provide(TestLayer))
|
|
166
178
|
})
|
|
167
179
|
|
|
168
180
|
it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
|
|
169
|
-
// B enables only when A has succeeded — the app's Session → Bootstrap shape.
|
|
170
|
-
// The dangerous instant: A flips Success while B is still Idle (Idle counts
|
|
171
|
-
// Ready), so a naive merge could notify Ready before B's driver flips it to
|
|
172
|
-
// Loading. Layer dependency order (B's driver subscribes to A's store before
|
|
173
|
-
// the boundary does) plus the one-microtask fixpoint flush close the gap;
|
|
174
|
-
// this records every notification to prove no intermediate Ready escapes.
|
|
175
181
|
class Seed extends State.make('seed', S.Number) {}
|
|
176
182
|
class Inputs extends StateGroup.make(Seed) {}
|
|
177
183
|
class A extends AsyncCalc.make('A', {
|
|
@@ -204,20 +210,16 @@ it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
|
|
|
204
210
|
})
|
|
205
211
|
expect(store.get()._tag).toBe('Pending')
|
|
206
212
|
|
|
207
|
-
yield* until(
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
yield* until(
|
|
214
|
+
() => store.get()._tag,
|
|
215
|
+
(t) => t === 'Ready',
|
|
216
|
+
)
|
|
217
|
+
yield* tick(30)
|
|
210
218
|
expect(observed).toEqual(['Ready'])
|
|
211
219
|
}).pipe(Effect.provide(TestLayer))
|
|
212
220
|
})
|
|
213
221
|
|
|
214
222
|
it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Ready', () => {
|
|
215
|
-
// The app's boot shape: AsyncCalc A → plain Calc projecting it → AsyncCalc B
|
|
216
|
-
// gated on the projection. Propagation crosses TWO scheduler-flush rounds,
|
|
217
|
-
// so when A settles there is a mid-flush frame where B has not yet flipped
|
|
218
|
-
// to Loading — a recompute-time latch would freeze that intermediate Ready
|
|
219
|
-
// and the boundary would abandon B's whole first load (caught live by the
|
|
220
|
-
// boot-frames walk: nav in, boards card still spinning).
|
|
221
223
|
class Seed extends State.make('seed', S.Number) {}
|
|
222
224
|
class Inputs extends StateGroup.make(Seed) {}
|
|
223
225
|
class A extends AsyncCalc.make('A', {
|
|
@@ -247,20 +249,24 @@ it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Rea
|
|
|
247
249
|
return Effect.gen(function* () {
|
|
248
250
|
const bStore = yield* B.store
|
|
249
251
|
const store = yield* Gate.store
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
yield* until(
|
|
253
|
+
() => bStore.get()._tag,
|
|
254
|
+
(t) => t === 'Loading',
|
|
255
|
+
)
|
|
253
256
|
expect(store.get()._tag).toBe('Pending')
|
|
254
|
-
yield* until(
|
|
255
|
-
|
|
257
|
+
yield* until(
|
|
258
|
+
() => bStore.get()._tag,
|
|
259
|
+
(t) => t === 'Success',
|
|
260
|
+
)
|
|
261
|
+
yield* until(
|
|
262
|
+
() => store.get()._tag,
|
|
263
|
+
(t) => t === 'Ready',
|
|
264
|
+
)
|
|
256
265
|
expect(store.get()._tag).toBe('Ready')
|
|
257
266
|
}).pipe(Effect.provide(TestLayer))
|
|
258
267
|
})
|
|
259
268
|
|
|
260
269
|
it.live('once: a covered gate enabling AFTER the latch never re-pends the boundary', () => {
|
|
261
|
-
// The boot-boundary semantics: a route-gated query (the board) first loads
|
|
262
|
-
// on a later navigation — its own screen owns that loading state, the shell
|
|
263
|
-
// splash must not come back.
|
|
264
270
|
class Route extends State.make('routeFlag', S.Boolean) {}
|
|
265
271
|
class Inputs extends StateGroup.make(Route) {}
|
|
266
272
|
class Always extends AsyncCalc.make('Always', {
|
|
@@ -289,13 +295,21 @@ it.live('once: a covered gate enabling AFTER the latch never re-pends the bounda
|
|
|
289
295
|
const route = yield* StateGroup.select(Inputs, 'routeFlag').store
|
|
290
296
|
const routedStore = yield* Routed.store
|
|
291
297
|
const store = yield* Gate.store
|
|
292
|
-
yield* until(
|
|
298
|
+
yield* until(
|
|
299
|
+
() => store.get()._tag,
|
|
300
|
+
(t) => t === 'Ready',
|
|
301
|
+
)
|
|
293
302
|
|
|
294
|
-
// "Navigate": the routed query enables and goes Loading — latched Ready holds.
|
|
295
303
|
route.set(true)
|
|
296
|
-
yield* until(
|
|
304
|
+
yield* until(
|
|
305
|
+
() => routedStore.get()._tag,
|
|
306
|
+
(t) => t === 'Loading',
|
|
307
|
+
)
|
|
297
308
|
expect(store.get()._tag).toBe('Ready')
|
|
298
|
-
yield* until(
|
|
309
|
+
yield* until(
|
|
310
|
+
() => routedStore.get()._tag,
|
|
311
|
+
(t) => t === 'Success',
|
|
312
|
+
)
|
|
299
313
|
expect(store.get()._tag).toBe('Ready')
|
|
300
314
|
}).pipe(Effect.provide(TestLayer))
|
|
301
315
|
})
|