@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.
@@ -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)
@@ -1,8 +1,6 @@
1
1
  import { expect, test } from 'vitest'
2
- import { Effect, Schema as S } from 'effect'
3
- import { Ui, isUi, ui } from '../index'
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, Effect, Option, type Schema } from 'effect'
3
- import { type Manifest, yieldableClass } from '../definition/definition'
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
- type EventsOf<C extends UiContract> = C extends { events: infer E } ? E : Record<never, never>
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
- export interface UiClass<C extends UiContract>
77
- extends Effect.Effect<LogicView<C>, never, ViewImpl<C>> {
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
- const read = Effect.gen(function* () {
148
- const render = yield* impl
149
- // Slots are bound by the host (`@reform/react`) per composition instance.
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. The logic resolves it (`yield* TodoAppUi`) to
183
- * a `(props, events) => node` view with slots already bound; `.make` authors the
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
@@ -31,6 +31,23 @@ export {
31
31
  type AsyncLoading,
32
32
  type AsyncSuccess,
33
33
  } from './calc/asyncData'
34
+ // The flat, data-oriented query value (`data`/`error`/`isFetching`/`isStale`) an
35
+ // `AsyncCalc` is driven by; `toAsyncData` projects it onto the matchable arms.
36
+ export { isLoading, type QueryState, toAsyncData } from './calc/queryState'
37
+ // Optional host seams an `AsyncCalc` consults — persistence and lifecycle signals.
38
+ // Concrete implementations live in edge packages (e.g. `@playfast/reform-query-browser`).
39
+ export {
40
+ noopQueryStore,
41
+ QueryStore,
42
+ type QueryStoreApi,
43
+ resolveQueryStore,
44
+ } from './internal/queryStore'
45
+ export {
46
+ noopQueryEvents,
47
+ QueryEvents,
48
+ type QueryEventsApi,
49
+ resolveQueryEvents,
50
+ } from './internal/queryEvents'
34
51
  export * as RemoteState from './remote/remoteState'
35
52
  // Direct type re-exports alongside the namespace (TS2742 nameability): an app
36
53
  // layer's inferred type references these (`Store<ReadonlyArray<PendingIntent<…>>>`),
@@ -56,8 +73,25 @@ export * as Composition from './compose/composition'
56
73
  export type {
57
74
  CompositionClass,
58
75
  CompositionService,
76
+ Frame,
59
77
  RenderEnv,
60
78
  } from './compose/composition'
79
+ export {
80
+ absent,
81
+ each,
82
+ type EachFill,
83
+ isStructure,
84
+ mount,
85
+ one,
86
+ type OneFill,
87
+ type AbsentFill,
88
+ type SlotFill,
89
+ type Structure,
90
+ structureEquals,
91
+ type StructureSlots,
92
+ StructureTypeId,
93
+ when,
94
+ } from './compose/structure'
61
95
  export { Props } from './compose/props'
62
96
  export {
63
97
  isSlot,
@@ -68,12 +102,11 @@ export {
68
102
  type SlotProps,
69
103
  SlotTypeId,
70
104
  } from './compose/slot'
71
- export { CurrentSlots, type SlotHost } from './compose/host'
105
+ export { type SlotRenderer } from './compose/host'
72
106
  export * as Ui from './compose/ui'
73
107
  export {
74
108
  type DerivedContract,
75
109
  isUi,
76
- type LogicView,
77
110
  type MadeView,
78
111
  ui,
79
112
  type UiClass,
@@ -130,10 +163,14 @@ export { type SlotChild } from './compose/slot'
130
163
  // A scene bundles a composition with the closed wiring that runs it — the single
131
164
  // handle the react host, proofs, and the dev tool all consume.
132
165
  export { isScene, type MountedServices, type Scene, scene, seedScene } from './scene/scene'
166
+ export type { SeedsOf } from './state/stateGroup'
133
167
 
134
168
  // Runtime surface for hosts (`@reform/react`) and headless tests.
135
169
  export { Bus, type Envelope, type Priority, publish, type Tagged } from './runtime/bus'
136
170
  export { Engine, type ReducerEntry, Reducers } from './runtime/loop'
171
+ // The runtime-wide registry of live query handles. `AsyncCalc.invalidate` /
172
+ // `AsyncCalc.refetch` and provider layers act on a query by name through it.
173
+ export { Queries, type QueryHandle, type QueryRegistry } from './runtime/queries'
137
174
  export {
138
175
  type ChannelClass,
139
176
  type ChannelPolicy,
@@ -151,7 +188,6 @@ export {
151
188
  FeatureLoadFailed,
152
189
  forceSync,
153
190
  InvalidProvideTarget,
154
- SlotRenderingUnavailable,
155
191
  UnknownGroupState,
156
192
  } from './internal/errors'
157
193
  // Notification scheduler: hosts that need per-runtime isolation (concurrent SSR,
@@ -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. */
@@ -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