@playfast/reform 0.1.0 → 1.1.1
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 +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +78 -173
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -202
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +48 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +10 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +131 -0
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
package/src/compose/provide.ts
CHANGED
|
@@ -4,54 +4,44 @@ import type { CompositionClass } from './composition'
|
|
|
4
4
|
import {
|
|
5
5
|
type EngineServices,
|
|
6
6
|
type FeatureClass,
|
|
7
|
+
type FeatureRequirement,
|
|
7
8
|
isFeature,
|
|
8
9
|
type ProvidedBy,
|
|
9
|
-
type RequireCarrier,
|
|
10
10
|
} from '../feature/feature'
|
|
11
11
|
import { isSlot, type SlotChild, type SlotClass } from './slot'
|
|
12
12
|
import { isUi, type UiClass, type UiContract, type ViewImpl } from './ui'
|
|
13
|
+
import type { AnyValue } from '../internal/variance'
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
-
* Wire an implementation to a contract. Provides a `.make` presentation to a UI
|
|
16
|
-
* contract, a child composition into a parent's slot, or a `Feature` into a slot.
|
|
17
|
-
* The compose code never imports its implementations — they meet here.
|
|
18
|
-
*/
|
|
19
15
|
export function provide<C extends UiContract>(
|
|
20
16
|
contract: UiClass<C>,
|
|
21
17
|
view: ViewImpl<C>,
|
|
22
18
|
): Layer.Layer<ViewImpl<C>>
|
|
23
|
-
export function provide<Comp extends CompositionClass<
|
|
19
|
+
export function provide<Comp extends CompositionClass<AnyValue, AnyValue>>(
|
|
24
20
|
hole: SlotClass<Comp>,
|
|
25
21
|
composition: Comp,
|
|
26
22
|
): Layer.Layer<CompositionClass<unknown>>
|
|
27
|
-
/**
|
|
28
|
-
* Fill a slot with a feature. The slot's child composition type
|
|
29
|
-
* (`CompositionClass<P, C>`) must match the feature's mounted composition, so the
|
|
30
|
-
* facade/contract stays typed. The returned layer surfaces the feature's shared
|
|
31
|
-
* requirement (`EngineServices | ProvidedBy<Requires>`) as an open `RIn`, so the
|
|
32
|
-
* scene that wires it won't compile unless it provides the matching `Core` — even
|
|
33
|
-
* for a lazy feature whose own `.live` layer isn't built until mount.
|
|
34
|
-
*/
|
|
35
23
|
export function provide<
|
|
36
|
-
Comp extends CompositionClass<
|
|
24
|
+
Comp extends CompositionClass<AnyValue, AnyValue>,
|
|
37
25
|
ROut,
|
|
38
|
-
Requires extends ReadonlyArray<
|
|
26
|
+
Requires extends ReadonlyArray<FeatureRequirement>,
|
|
27
|
+
SupportRequires extends ReadonlyArray<FeatureRequirement>,
|
|
39
28
|
>(
|
|
40
29
|
hole: SlotClass<Comp>,
|
|
41
30
|
feature: FeatureClass<
|
|
42
|
-
Comp extends CompositionClass<infer P,
|
|
43
|
-
Comp extends CompositionClass<
|
|
31
|
+
Comp extends CompositionClass<infer P, AnyValue> ? P : never,
|
|
32
|
+
Comp extends CompositionClass<AnyValue, infer C> ? C : never,
|
|
44
33
|
ROut,
|
|
45
|
-
Requires
|
|
34
|
+
Requires,
|
|
35
|
+
SupportRequires
|
|
46
36
|
>,
|
|
47
|
-
): Layer.Layer<
|
|
37
|
+
): Layer.Layer<
|
|
38
|
+
SlotChild,
|
|
39
|
+
never,
|
|
40
|
+
EngineServices | ProvidedBy<Requires> | ProvidedBy<SupportRequires>
|
|
41
|
+
>
|
|
48
42
|
// oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- erased implementation signature of the 2-arg overloaded `provide`; the typed public API is the overloads above
|
|
49
43
|
export function provide(target: unknown, impl: unknown): Layer.Layer<never, never, unknown> {
|
|
50
|
-
//
|
|
51
|
-
// happens to exist: a UI contract provides under its `impl` tag, a slot under
|
|
52
|
-
// its `tag`. Anything else is a wiring mistake. The overloads above pair
|
|
53
|
-
// `impl` with the chosen tag; this implementation sees both erased, so view
|
|
54
|
-
// the tag loosely (the one documented cast) to provide the service under it.
|
|
44
|
+
// Brand-discriminate: UI → impl tag, slot → tag; cast is the erased DI seam
|
|
55
45
|
// oxlint-disable-next-line reform-rules/no-type-assertion -- erased DI tag widening at the provide seam (Context.Tag is invariant); overloads pair impl with the right tag
|
|
56
46
|
const tag = (isUi(target) ? target.impl : isSlot(target) ? target.tag : undefined) as
|
|
57
47
|
| Context.Tag<unknown, unknown>
|
|
@@ -60,15 +50,11 @@ export function provide(target: unknown, impl: unknown): Layer.Layer<never, neve
|
|
|
60
50
|
return Layer.die(new InvalidProvideTarget())
|
|
61
51
|
}
|
|
62
52
|
if (isFeature(impl)) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// placeholders. Default: bind the composition (so the existing `Compose` path
|
|
66
|
-
// renders it, no host, no flash) and merge the eager `.live` layer, whose `RIn`
|
|
67
|
-
// surfaces to the scene. Either way the overload's return type carries the
|
|
68
|
-
// shared `RIn`, so the scene still type-demands `Core`.
|
|
53
|
+
// Default: merge live module now; lazy: eager placeholder support only
|
|
54
|
+
const eager = impl.eagerModule ?? impl.supportModule
|
|
69
55
|
return eager === undefined
|
|
70
56
|
? Layer.succeed(tag, impl.binding)
|
|
71
|
-
: Layer.merge(Layer.succeed(tag, impl.binding
|
|
57
|
+
: Layer.merge(Layer.succeed(tag, impl.binding), eager.layer)
|
|
72
58
|
}
|
|
73
59
|
return Layer.succeed(tag, impl)
|
|
74
60
|
}
|
package/src/compose/slot.ts
CHANGED
|
@@ -3,41 +3,18 @@ import { definitionClass } from '../definition/definition'
|
|
|
3
3
|
import type { CompositionClass } from './composition'
|
|
4
4
|
import type { FeatureBinding } from '../feature/feature'
|
|
5
5
|
import type { Node } from '../ui/node'
|
|
6
|
+
import type { AnyValue } from '../internal/variance'
|
|
6
7
|
|
|
7
8
|
export type { Node }
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
type AnyComposition = CompositionClass<any, any>
|
|
10
|
+
type AnyComposition = CompositionClass<AnyValue, AnyValue>
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* What a slot can be filled with: a plain child composition (the host renders it
|
|
14
|
-
* directly), or a `FeatureBinding` from `provide(slot, Feature)` (the host drives
|
|
15
|
-
* its lazy/eager mount lifecycle). The slot's DI tag holds one of these; the host
|
|
16
|
-
* discriminates by the `FeatureBinding` brand.
|
|
17
|
-
*/
|
|
18
12
|
export type SlotChild = CompositionClass<unknown> | FeatureBinding
|
|
19
13
|
|
|
20
|
-
/**
|
|
21
|
-
* Phantom instance shape: `ItemSlot` used as a *type* denotes this. It carries
|
|
22
|
-
* the child *composition* type `Comp`, so the slot knows both the external props
|
|
23
|
-
* the parent renders it with (`Comp['Props']`) and the child's full UI contract
|
|
24
|
-
* (`Comp['Contract']` — props + events + slots) that reflection consumers, the
|
|
25
|
-
* proof facade, drive.
|
|
26
|
-
*/
|
|
27
14
|
export interface SlotInstance<Comp extends AnyComposition> {
|
|
28
15
|
readonly _slot: Comp
|
|
29
16
|
}
|
|
30
17
|
|
|
31
|
-
/**
|
|
32
|
-
* A DI hole for a child composition. The parent declares
|
|
33
|
-
* `slot('Item')<typeof TodoItem>()` and renders through it; the concrete child
|
|
34
|
-
* is wired with `provide(Item, TodoItem)`, so the parent never imports the
|
|
35
|
-
* child's *implementation* (the `typeof` is a type-only reference to the
|
|
36
|
-
* definition). `Comp` lives only in the phantom instance (so `SlotClass` is
|
|
37
|
-
* covariant and a `{ Item: ItemSlot }` map is well-typed); the match is enforced
|
|
38
|
-
* by `provide`'s signature, not the (invariant) tag.
|
|
39
|
-
*/
|
|
40
|
-
/** Nominal brand identifying a slot at runtime (Effect's `TypeId` idiom). */
|
|
41
18
|
export const SlotTypeId: unique symbol = Symbol.for('reform/Slot')
|
|
42
19
|
export type SlotTypeId = typeof SlotTypeId
|
|
43
20
|
|
|
@@ -45,23 +22,18 @@ export interface SlotClass<Comp extends AnyComposition = AnyComposition> {
|
|
|
45
22
|
new (): SlotInstance<Comp>
|
|
46
23
|
readonly [SlotTypeId]: SlotTypeId
|
|
47
24
|
readonly kind: 'Slot'
|
|
48
|
-
/** Internal DI tag the filling child (composition or feature binding) is provided under. */
|
|
49
25
|
readonly tag: Context.Tag<SlotChild, SlotChild>
|
|
50
26
|
}
|
|
51
27
|
|
|
52
|
-
/** Whether a value is a slot — discriminates a `provide` target by brand. */
|
|
53
28
|
export const isSlot = (candidate: unknown): candidate is SlotClass =>
|
|
54
29
|
(typeof candidate === 'function' || typeof candidate === 'object') &&
|
|
55
30
|
candidate !== null &&
|
|
56
31
|
SlotTypeId in candidate
|
|
57
32
|
|
|
58
|
-
/** The props a slot renders with — the child composition's external props. */
|
|
59
33
|
export type SlotProps<S> = S extends SlotInstance<infer Comp> ? Comp['Props'] : never
|
|
60
34
|
|
|
61
35
|
export const slot =
|
|
62
36
|
(name: string) =>
|
|
63
|
-
// Defaulted so a slot whose child carries no meaningful props/contract can be
|
|
64
|
-
// declared bare (`slot('Tree')()`); pass `typeof Child` to type the facade.
|
|
65
37
|
<Comp extends AnyComposition = AnyComposition>(): SlotClass<Comp> => {
|
|
66
38
|
const tag = Context.GenericTag<SlotChild, SlotChild>(`reform/slot/${name}`)
|
|
67
39
|
return definitionClass<SlotClass<Comp>>({ [SlotTypeId]: SlotTypeId, kind: 'Slot' as const, tag })
|
|
@@ -7,10 +7,6 @@ import { absent, each, isStructure, mount, one, structureEquals, when } from './
|
|
|
7
7
|
|
|
8
8
|
type List = Contract<typeof ListUi>
|
|
9
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
10
|
class RowUi extends ui('Row')<{ props: { label: string } }>() {}
|
|
15
11
|
class Row extends Composition.make('Row', {
|
|
16
12
|
title: 'Row',
|
|
@@ -65,8 +61,6 @@ describe('structure combinators', () => {
|
|
|
65
61
|
})
|
|
66
62
|
})
|
|
67
63
|
|
|
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
64
|
export const _typeChecks = (): void => {
|
|
71
65
|
mount<List>({
|
|
72
66
|
props: { total: 1 },
|
package/src/compose/structure.ts
CHANGED
|
@@ -2,64 +2,29 @@ import { Struct } from 'effect'
|
|
|
2
2
|
import type { SlotProps } from './slot'
|
|
3
3
|
import type { EventsOf, UiContract } from './ui'
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Structure-as-data (the headless-structure keystone). A composition's logic
|
|
7
|
-
* body returns this *value* instead of a React node: pure, serializable data that
|
|
8
|
-
* describes — for this frame — the composition's own props and, per declared slot,
|
|
9
|
-
* how that slot is filled (a list / a singleton / absent). The engine, the wire
|
|
10
|
-
* server, and the proof harness consume this directly, so none of them needs to
|
|
11
|
-
* evaluate a view or import React to discover structure. Presentation (React)
|
|
12
|
-
* resolves a contract's markup separately, by name, on a host.
|
|
13
|
-
*
|
|
14
|
-
* `live` accepts BOTH a `Node` (legacy view body) and a `Structure` during the
|
|
15
|
-
* migration; hosts discriminate at runtime via {@link isStructure}.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** A list slot fill: one keyed entry per item. `key` is the SINGLE source of both
|
|
19
|
-
* the wire `key` and the per-item `StateFamily` key — they can no longer drift. */
|
|
20
5
|
export interface EachFill<P> {
|
|
21
6
|
readonly _tag: 'Each'
|
|
22
7
|
readonly items: ReadonlyArray<{ readonly key: string; readonly props: P }>
|
|
23
8
|
}
|
|
24
9
|
|
|
25
|
-
/** A singleton slot fill — the slot renders exactly one child with these props. */
|
|
26
10
|
export interface OneFill<P> {
|
|
27
11
|
readonly _tag: 'One'
|
|
28
12
|
readonly props: P
|
|
29
13
|
}
|
|
30
14
|
|
|
31
|
-
/** A slot that renders nothing this frame (the data-driven analog of `cond && …`). */
|
|
32
15
|
export interface AbsentFill {
|
|
33
16
|
readonly _tag: 'Absent'
|
|
34
17
|
}
|
|
35
18
|
|
|
36
|
-
/** How one declared slot is filled this frame, typed by the child's external props. */
|
|
37
19
|
export type SlotFill<P> = EachFill<P> | OneFill<P> | AbsentFill
|
|
38
20
|
|
|
39
|
-
/** The per-slot fill map a contract's structure carries — one fill per declared slot,
|
|
40
|
-
* each typed by that child composition's external props (`SlotProps`). Empty when the
|
|
41
|
-
* contract declares no slots. */
|
|
42
21
|
export type StructureSlots<C extends UiContract> = C extends {
|
|
43
22
|
slots: infer S
|
|
44
23
|
}
|
|
45
24
|
? { readonly [K in keyof S]: SlotFill<SlotProps<S[K]>> }
|
|
46
25
|
: Record<never, never>
|
|
47
26
|
|
|
48
|
-
|
|
49
|
-
* The data a composition's logic returns for one frame: its computed props plus,
|
|
50
|
-
* for every declared slot, a fill description. Strict against the contract — props
|
|
51
|
-
* must match, and every declared slot must be filled (use {@link when} for
|
|
52
|
-
* conditional presence). Carries the child's contract per slot (via `SlotProps`),
|
|
53
|
-
* so consumers can select and descend into sub-compositions type-safely.
|
|
54
|
-
*
|
|
55
|
-
* `events` rides ON the structure: because a `mount(...)`-returning body never
|
|
56
|
-
* calls its view, the event triggers it acquired (`yield* Event.trigger(X)`) are
|
|
57
|
-
* no longer captured by a view's `CaptureSink` record — they must travel with the
|
|
58
|
-
* frame so hosts can register them (proof `actions`, the wire `TriggerRegistry`,
|
|
59
|
-
* the react presentation `events` arg). Triggers are functions, so `events` is NOT
|
|
60
|
-
* serializable and is intentionally excluded from {@link structureEquals}. Typed to
|
|
61
|
-
* the contract's event map; omit it (defaults to `{}`) when the contract has none.
|
|
62
|
-
*/
|
|
27
|
+
// events ride on the frame for hosts but are not serializable — excluded from structureEquals
|
|
63
28
|
export interface Structure<C extends UiContract> {
|
|
64
29
|
readonly props: C['props']
|
|
65
30
|
readonly slots: StructureSlots<C>
|
|
@@ -67,29 +32,14 @@ export interface Structure<C extends UiContract> {
|
|
|
67
32
|
readonly events?: EventsOf<C>
|
|
68
33
|
}
|
|
69
34
|
|
|
70
|
-
/** Runtime brand distinguishing a `Structure` value from a React `Node` (added by
|
|
71
|
-
* {@link mount}; the authoring TYPE intentionally omits it so structure literals stay
|
|
72
|
-
* plain). */
|
|
73
35
|
export const StructureTypeId: unique symbol = Symbol.for('reform/Structure')
|
|
74
36
|
export type StructureTypeId = typeof StructureTypeId
|
|
75
37
|
|
|
76
|
-
/**
|
|
77
|
-
* Assemble a frame's structure, checked against the contract `C`. Identity at
|
|
78
|
-
* runtime apart from stamping the {@link StructureTypeId} brand; its real job is
|
|
79
|
-
* the compile-time contract check. `C` is recovered from the surrounding `live`
|
|
80
|
-
* body's return type (contextual typing), so call sites read `mount({ props, slots })`.
|
|
81
|
-
* A leaf composition (no declared slots) passes `slots: {}`. `events` defaults to an
|
|
82
|
-
* empty map when omitted, so a frame always carries an `events` record for hosts.
|
|
83
|
-
*/
|
|
84
38
|
export const mount = <C extends UiContract>(structure: Structure<C>): Structure<C> =>
|
|
85
39
|
// oxlint-disable-next-line reform-rules/no-object-assign -- merge infers the empty `events` default as EventsOf<C>; a spread would force an `as` cast instead
|
|
86
40
|
Object.assign({ [StructureTypeId]: StructureTypeId, events: {} }, structure)
|
|
87
41
|
|
|
88
|
-
|
|
89
|
-
* A list slot fill. `key` derives the stable identity for BOTH the wire node and
|
|
90
|
-
* the per-item state family — passing the same id source to both is no longer
|
|
91
|
-
* possible to get wrong. `props` maps each item to the child's external props.
|
|
92
|
-
*/
|
|
42
|
+
// key is the single source for both wire key and per-item StateFamily key
|
|
93
43
|
export interface EachOptions<T, P> {
|
|
94
44
|
readonly key: (item: T) => string
|
|
95
45
|
readonly props: (item: T) => P
|
|
@@ -103,23 +53,16 @@ export const each = <T, P>(collection: Iterable<T>, options: EachOptions<T, P>):
|
|
|
103
53
|
})),
|
|
104
54
|
})
|
|
105
55
|
|
|
106
|
-
/** A singleton slot fill — render one child with these external props. */
|
|
107
56
|
export const one = <P>(props: P): OneFill<P> => ({ _tag: 'One', props })
|
|
108
57
|
|
|
109
|
-
/** The shared empty fill — a slot that renders nothing this frame. */
|
|
110
58
|
export const absent: AbsentFill = { _tag: 'Absent' }
|
|
111
59
|
|
|
112
|
-
/** Conditional presence: the fill when `cond`, otherwise {@link absent}. */
|
|
113
60
|
export const when = <F extends SlotFill<unknown>>(cond: boolean, fill: F): F | AbsentFill =>
|
|
114
61
|
cond ? fill : absent
|
|
115
62
|
|
|
116
|
-
/** Whether a frame value is a `Structure` (vs a legacy React `Node`). The runtime
|
|
117
|
-
* discriminator hosts use during the migration, replacing React's `isValidElement`. */
|
|
118
63
|
export const isStructure = (frame: unknown): frame is Structure<UiContract> =>
|
|
119
64
|
typeof frame === 'object' && frame !== null && StructureTypeId in frame
|
|
120
65
|
|
|
121
|
-
/** Whether a value is a plain record (not an array) — narrows for the deep compare
|
|
122
|
-
* below without a cast. */
|
|
123
66
|
const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
124
67
|
typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
|
|
125
68
|
|
|
@@ -128,8 +71,6 @@ interface ValuePair {
|
|
|
128
71
|
readonly right: unknown
|
|
129
72
|
}
|
|
130
73
|
|
|
131
|
-
/** Minimal structural deep-equality for serializable shapes (plain JSON — the
|
|
132
|
-
* UI-contract guarantee). Avoids a heavyweight dep for the settle comparison. */
|
|
133
74
|
const deepEquals = ({ left, right }: ValuePair): boolean => {
|
|
134
75
|
if (Object.is(left, right)) {
|
|
135
76
|
return true
|
|
@@ -155,12 +96,7 @@ const deepEquals = ({ left, right }: ValuePair): boolean => {
|
|
|
155
96
|
return false
|
|
156
97
|
}
|
|
157
98
|
|
|
158
|
-
|
|
159
|
-
* value. `events` is intentionally excluded: triggers are functions, freshly
|
|
160
|
-
* acquired each render, so comparing them by identity would never match and would
|
|
161
|
-
* break the settle fixpoint. The `StructureTypeId` brand is a symbol key, so
|
|
162
|
-
* `Object.keys` skips it. Used by the proof/server settle loop to detect a
|
|
163
|
-
* two-frame fixpoint. */
|
|
99
|
+
// events intentionally excluded (fresh function identities would break settle fixpoint)
|
|
164
100
|
export const structureEquals = (
|
|
165
101
|
left: Structure<UiContract>,
|
|
166
102
|
right: Structure<UiContract>,
|
package/src/compose/ui.test.ts
CHANGED
|
@@ -2,10 +2,6 @@ import { expect, test } from 'vitest'
|
|
|
2
2
|
import { Schema as S } from 'effect'
|
|
3
3
|
import { isUi, ui } from '../index'
|
|
4
4
|
|
|
5
|
-
// `ui` has two authoring forms with one source of truth each: the type-only form
|
|
6
|
-
// (local, no schema) and the schema form (wired, the type is *derived*). Both are
|
|
7
|
-
// the same contract at runtime; only the wired form carries reflectable schemas.
|
|
8
|
-
|
|
9
5
|
test('type-only `ui` carries no wire schema', () => {
|
|
10
6
|
class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
|
|
11
7
|
expect(CounterUi.manifest.kind).toBe('Ui')
|
package/src/compose/ui.ts
CHANGED
|
@@ -3,50 +3,25 @@ import { Context, type Schema } from 'effect'
|
|
|
3
3
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
4
4
|
import type { Trigger } from '../ui/trigger'
|
|
5
5
|
import { type Node, type SlotClass, type SlotInstance, type SlotProps } from './slot'
|
|
6
|
+
import type { AnyValue } from '../internal/variance'
|
|
6
7
|
|
|
7
8
|
export interface UiContract {
|
|
8
9
|
props: unknown
|
|
9
|
-
//
|
|
10
|
-
// stays terse AND so `C extends { slots: infer S }` / `{ events: infer E }`
|
|
11
|
-
// discriminates their presence (SlotsOf/EventsOf). Option<T> would break both the
|
|
12
|
-
// ergonomic authoring form and that conditional-type inference.
|
|
10
|
+
// Optional so local `ui('X')<{ props }>()` stays terse and `C extends { slots/events: … }` discriminates presence
|
|
13
11
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` contract inference (see note above)
|
|
14
|
-
slots?: Record<string, SlotInstance<
|
|
12
|
+
slots?: Record<string, SlotInstance<AnyValue>>
|
|
15
13
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` contract inference (see note above)
|
|
16
|
-
events?: Record<string, Trigger<
|
|
14
|
+
events?: Record<string, Trigger<AnyValue>>
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
type PropsOf<C extends UiContract> = C['props']
|
|
20
|
-
/** The contract's event map (`{ [name]: Trigger<P> }`), or empty when it declares
|
|
21
|
-
* none. Exported so a `Structure<C>` can type its optional `events` field to the
|
|
22
|
-
* same map a composition body acquires. */
|
|
23
18
|
export type EventsOf<C extends UiContract> = C extends { events: infer E } ? E : Record<never, never>
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// but a placeholder render (the remote client, where the per-child props were captured
|
|
27
|
-
// server-side and the framework expands the children) omits them (`<slots.Item />`).
|
|
28
|
-
// One `Ui.make` view thus serves both renderers. The child still reads its own
|
|
29
|
-
// props/state — these are only the external props the parent feeds, advisory at the
|
|
30
|
-
// slot boundary. Typing it as `FunctionComponent` makes both JSX and `createElement` work.
|
|
31
|
-
//
|
|
32
|
-
// `slotKey` is the universal KEYED-SLOT selector (see `WireNode.key`): for a LIST slot
|
|
33
|
-
// invoked once per item (`<slots.Row slotKey={id} />`), the remote client renders only the
|
|
34
|
-
// one wire child whose React `key` matches, instead of every child of that slot at every
|
|
35
|
-
// call site (which duplicates the whole list under each call). It is framework-handled, not
|
|
36
|
-
// forwarded to the child, so it is allowed on EVERY slot and never collides with a child's
|
|
37
|
-
// own props. Omit it for singleton slots (`<slots.Create />`) — they render all children.
|
|
19
|
+
// Slots are Partial FunctionComponents: parent may supply props; remote placeholder omits them.
|
|
20
|
+
// slotKey selects one keyed list child on the wire; framework-handled, never a child prop.
|
|
38
21
|
type SlotsOf<C extends UiContract> = C extends { slots: infer S }
|
|
39
22
|
? {
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// the component assignable to a bare `(props: unknown) => Node` slot-renderer shape. An
|
|
43
|
-
// outer intersection would leave a required-shape object that `unknown` can't satisfy.
|
|
44
|
-
//
|
|
45
|
-
// A slot handle is BOTH renderable (the `FunctionComponent` — places the keyed children)
|
|
46
|
-
// AND inspectable (`.props` — the array of per-child fill props the structure carried, one
|
|
47
|
-
// entry per mounted child, typed by the child contract). A presentation can read the fills
|
|
48
|
-
// (`slots.Item.props.length`, a header, layout decisions) and still render them
|
|
49
|
-
// (`<slots.Item/>`); it never DERIVES multiplicity — the engine already fixed it. Read-only.
|
|
23
|
+
// slotKey inside Partial so `any` child props still collapse to `any` (outer & would break unknown)
|
|
24
|
+
// Handle = renderable FC + inspectable `.props` fills (engine fixes multiplicity)
|
|
50
25
|
readonly [K in keyof S]: FunctionComponent<
|
|
51
26
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- React/JSX component-prop shape (`<slots.Row slotKey={id} />`); singleton slots omit it, framework-handled
|
|
52
27
|
Partial<SlotProps<S[K]> & { readonly slotKey?: string }>
|
|
@@ -54,84 +29,51 @@ type SlotsOf<C extends UiContract> = C extends { slots: infer S }
|
|
|
54
29
|
}
|
|
55
30
|
: Record<never, never>
|
|
56
31
|
|
|
57
|
-
/** What `.make` authors: the pure presentation. Slots are injected by the renderer. */
|
|
58
32
|
export type ViewImpl<C extends UiContract> = (
|
|
59
33
|
props: PropsOf<C>,
|
|
60
34
|
slots: SlotsOf<C>,
|
|
61
35
|
events: EventsOf<C>,
|
|
62
36
|
) => Node
|
|
63
37
|
|
|
64
|
-
/** Nominal brand identifying a UI contract at runtime (Effect's `TypeId` idiom). */
|
|
65
38
|
export const UiTypeId: unique symbol = Symbol.for('reform/Ui')
|
|
66
39
|
export type UiTypeId = typeof UiTypeId
|
|
67
40
|
|
|
68
|
-
/**
|
|
69
|
-
* The reflectable descriptor a contract carries. The wire `props` / `events`
|
|
70
|
-
* schemas are present *only* on the schema (wired) form — the type-only form
|
|
71
|
-
* carries neither, so a local contract has no schema cost and no schema surface.
|
|
72
|
-
*/
|
|
73
41
|
export interface UiManifest extends Manifest {
|
|
74
42
|
readonly kind: 'Ui'
|
|
75
|
-
//
|
|
76
|
-
// branded/Class schemas; only the decoded type is ever read off these.
|
|
77
|
-
// Present only on the wired form, so `extends { props: … }` discriminates the two
|
|
78
|
-
// authoring forms — Option<T> would erase that wire/type-only distinction.
|
|
43
|
+
// Schema variance needs any in encoded/context; present only on wired form for discrimination
|
|
79
44
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- wire manifest field present only on the wired form (see note above)
|
|
80
|
-
readonly props?: Schema.Schema
|
|
45
|
+
readonly props?: Schema.Schema.AnyNoContext
|
|
81
46
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- wire manifest field present only on the wired form (see note above)
|
|
82
|
-
readonly events?: Readonly<Record<string, Schema.Schema
|
|
47
|
+
readonly events?: Readonly<Record<string, Schema.Schema.AnyNoContext>>
|
|
83
48
|
}
|
|
84
49
|
|
|
85
|
-
/** A `UiManifest` whose wire schema is present — the runtime brand of a remote-capable contract. */
|
|
86
50
|
export interface WiredUiManifest extends UiManifest {
|
|
87
|
-
readonly props: Schema.Schema
|
|
51
|
+
readonly props: Schema.Schema.AnyNoContext
|
|
88
52
|
}
|
|
89
53
|
|
|
90
|
-
//
|
|
91
|
-
// yieldable: bodies return a `Structure`, and the host reads `impl` to resolve the view.
|
|
54
|
+
// Not yieldable: bodies return Structure; host resolves view via `impl`
|
|
92
55
|
export interface UiClass<C extends UiContract> {
|
|
93
56
|
new (): {}
|
|
94
57
|
readonly [UiTypeId]: UiTypeId
|
|
95
58
|
readonly manifest: UiManifest
|
|
96
|
-
/** Internal DI tag the `Ui.make` presentation is provided under. */
|
|
97
59
|
readonly impl: Context.Tag<ViewImpl<C>, ViewImpl<C>>
|
|
98
60
|
}
|
|
99
61
|
|
|
100
|
-
/**
|
|
101
|
-
* Recover a contract's `UiContract` type from its `Ui` class — `Ui.Contract<typeof
|
|
102
|
-
* SomeUi>`. Lets a consumer (a proof, a reflection tool) name the contract derived
|
|
103
|
-
* from a wired `ui('X', {…})` without re-declaring it.
|
|
104
|
-
*/
|
|
105
62
|
export type Contract<U> = U extends UiClass<infer C> ? C : never
|
|
106
63
|
|
|
107
|
-
/**
|
|
108
|
-
* A contract authored with wire schemas. Structurally a `UiClass<C>` whose
|
|
109
|
-
* manifest *carries* the schemas (`WiredUiManifest`), so it is the subtype the
|
|
110
|
-
* remote seam (`provideRemote`, the wire registry) demands while plain `provide`
|
|
111
|
-
* / `scene` / `Composition.make` keep accepting any `UiClass<C>`.
|
|
112
|
-
*/
|
|
113
64
|
export type WiredUi<C extends UiContract> = UiClass<C> & { readonly manifest: WiredUiManifest }
|
|
114
65
|
|
|
115
|
-
/** The wire schemas a contract is authored from: props, a payload schema per event, and
|
|
116
|
-
* (type-only) the slots the view fills. Slots carry no wire schema — the children that
|
|
117
|
-
* fill them are their own contracts — but declaring them here threads typed slots into
|
|
118
|
-
* the derived contract, so a WIRED contract can also have slots (e.g. a parent whose
|
|
119
|
-
* props the client reads AND that renders slot children). */
|
|
120
66
|
export interface WireSchemas {
|
|
121
|
-
readonly props: Schema.Schema
|
|
122
|
-
// `events`/`slots` optional so `Sch extends { events: … }` / `{ slots: … }`
|
|
123
|
-
// derives the contract's facade only when declared (DerivedEvents/DerivedSlots).
|
|
67
|
+
readonly props: Schema.Schema.AnyNoContext
|
|
124
68
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` derivation (DerivedEvents)
|
|
125
|
-
readonly events?: Readonly<Record<string, Schema.Schema
|
|
126
|
-
//
|
|
127
|
-
// same values `Composition.make({ slots })` takes — NOT `SlotInstance`s. The
|
|
128
|
-
// contract's slot facade is derived from them below.
|
|
69
|
+
readonly events?: Readonly<Record<string, Schema.Schema.AnyNoContext>>
|
|
70
|
+
// Slot CLASSES (not instances) so the contract facade derives InstanceType
|
|
129
71
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` derivation (DerivedSlots)
|
|
130
72
|
readonly slots?: Readonly<Record<string, SlotClass>>
|
|
131
73
|
}
|
|
132
74
|
|
|
133
75
|
type DerivedEvents<Sch extends WireSchemas> = Sch extends {
|
|
134
|
-
readonly events: infer E extends Record<string, Schema.Schema
|
|
76
|
+
readonly events: infer E extends Record<string, Schema.Schema.AnyNoContext>
|
|
135
77
|
}
|
|
136
78
|
? { readonly [K in keyof E]: Trigger<Schema.Schema.Type<E[K]>> }
|
|
137
79
|
: Record<never, never>
|
|
@@ -142,19 +84,12 @@ type DerivedSlots<Sch extends WireSchemas> = Sch extends {
|
|
|
142
84
|
? { readonly [K in keyof S]: InstanceType<S[K]> }
|
|
143
85
|
: Record<never, never>
|
|
144
86
|
|
|
145
|
-
/**
|
|
146
|
-
* The `UiContract` a set of wire schemas denotes — props and event-payload
|
|
147
|
-
* *types* derived from the schemas via `Schema.Type`, plus any declared slots. The
|
|
148
|
-
* schema is the single source of truth; the contract type is inferred, never written
|
|
149
|
-
* a second time.
|
|
150
|
-
*/
|
|
151
87
|
export type DerivedContract<Sch extends WireSchemas> = {
|
|
152
88
|
readonly props: Schema.Schema.Type<Sch['props']>
|
|
153
89
|
readonly slots: DerivedSlots<Sch>
|
|
154
90
|
readonly events: DerivedEvents<Sch>
|
|
155
91
|
}
|
|
156
92
|
|
|
157
|
-
/** Whether a value is a UI contract — discriminates a `provide` target by brand. */
|
|
158
93
|
export const isUi = (candidate: unknown): candidate is UiClass<UiContract> =>
|
|
159
94
|
(typeof candidate === 'function' || typeof candidate === 'object') &&
|
|
160
95
|
candidate !== null &&
|
|
@@ -165,8 +100,6 @@ const buildUi = <C extends UiContract, M extends UiManifest>(
|
|
|
165
100
|
manifest: M,
|
|
166
101
|
): UiClass<C> & { readonly manifest: M } => {
|
|
167
102
|
const impl = Context.GenericTag<ViewImpl<C>, ViewImpl<C>>(`reform/ui/${name}`)
|
|
168
|
-
// Extendable definition class (`class Counter extends ui('Counter', …) {}`); resolved
|
|
169
|
-
// by name on a host via `impl`, never yielded.
|
|
170
103
|
return definitionClass<UiClass<C> & { readonly manifest: M }>({
|
|
171
104
|
[UiTypeId]: UiTypeId,
|
|
172
105
|
manifest,
|
|
@@ -174,19 +107,6 @@ const buildUi = <C extends UiContract, M extends UiManifest>(
|
|
|
174
107
|
})
|
|
175
108
|
}
|
|
176
109
|
|
|
177
|
-
/**
|
|
178
|
-
* A renderer-neutral UI contract: `.make` authors the presentation, a composition
|
|
179
|
-
* names it, and `@reform/react` resolves it to a view. Core defines the contract.
|
|
180
|
-
*
|
|
181
|
-
* Two authoring forms, one source of truth each:
|
|
182
|
-
*
|
|
183
|
-
* - **Type-only (local):** `ui('Counter')<{ props: { count: number } }>()` — no
|
|
184
|
-
* schema, no ceremony, exactly as before.
|
|
185
|
-
* - **Schema (wired):** `ui('Counter', { props: S.Struct({ count: S.Number }) })`
|
|
186
|
-
* — the schema *is* the definition; the contract type is derived via
|
|
187
|
-
* `Schema.Type` (never re-written), and the result is a `WiredUi` the remote
|
|
188
|
-
* seam accepts. Make a local contract remote by rewriting this one line.
|
|
189
|
-
*/
|
|
190
110
|
export function ui(name: string): <C extends UiContract>() => UiClass<C>
|
|
191
111
|
export function ui<const Sch extends WireSchemas>(name: string, schemas: Sch): WiredUi<DerivedContract<Sch>>
|
|
192
112
|
// oxlint-disable-next-line reform-rules/min-var-name -- `ui` is the public framework contract-authoring API; renaming would break every caller across the monorepo
|
|
@@ -199,25 +119,11 @@ export function ui(name: string, schemas?: WireSchemas): unknown {
|
|
|
199
119
|
return buildUi<DerivedContract<WireSchemas>, WiredUiManifest>(name, manifest)
|
|
200
120
|
}
|
|
201
121
|
|
|
202
|
-
/** Brand under which a `Ui.make` view carries its own contract. */
|
|
203
122
|
export const UiViewContract: unique symbol = Symbol.for('reform/ui/view-contract')
|
|
204
123
|
export type UiViewContract = typeof UiViewContract
|
|
205
124
|
|
|
206
|
-
/**
|
|
207
|
-
* A `Ui.make` view that carries its contract, so a consumer can recover the
|
|
208
|
-
* contract (its wire name + props schema) straight from the view — no separate
|
|
209
|
-
* registration call. This is what makes the remote client view-set
|
|
210
|
-
* (`remoteViews<AppContract>({ Some: SomeView, … })`) work with the very same
|
|
211
|
-
* `Ui.make` views the local renderer uses.
|
|
212
|
-
*/
|
|
213
125
|
export type MadeView<C extends UiContract> = ViewImpl<C> & { readonly [UiViewContract]: UiClass<C> }
|
|
214
126
|
|
|
215
|
-
/**
|
|
216
|
-
* Author the pure presentation for a contract — `Ui.make(TodoAppUi, (props,
|
|
217
|
-
* slots, events) => node)`. The contract argument fixes the view's types AND is
|
|
218
|
-
* carried on the returned view (`MadeView`), so the same view is reusable as a
|
|
219
|
-
* remote client presentation. Wire the result with `provide(contract, view)`.
|
|
220
|
-
*/
|
|
221
127
|
export const make = <C extends UiContract>(contract: UiClass<C>, view: ViewImpl<C>): MadeView<C> =>
|
|
222
128
|
// oxlint-disable-next-line reform-rules/no-object-assign -- `view` is a callable function; spread would drop callability, so the brand is attached in place
|
|
223
129
|
Object.assign(view, { [UiViewContract]: contract })
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
// Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
|
|
2
|
-
// These guard the schema-first seam: the wired form is the single source of
|
|
3
|
-
// truth (props/event types derived, never re-written), a wired contract is a
|
|
4
|
-
// plain `UiClass` (so `provide` / `scene` / `Composition.make` accept it), and
|
|
5
|
-
// the remote seam (`WiredUi`) rejects a type-only contract. If a guarantee
|
|
6
|
-
// regresses, a `@ts-expect-error` goes unused or an assignment fails, and `tsc`
|
|
7
|
-
// breaks.
|
|
8
|
-
|
|
9
1
|
import { Schema as S } from 'effect'
|
|
10
2
|
import {
|
|
11
3
|
type Contract,
|
|
@@ -32,20 +24,14 @@ const wiredSchemas: {
|
|
|
32
24
|
const WiredUiClassBase: WiredUi<DerivedContract<typeof wiredSchemas>> = ui('Wired', wiredSchemas)
|
|
33
25
|
class WiredUiClass extends WiredUiClassBase {}
|
|
34
26
|
|
|
35
|
-
// POSITIVE: both forms are a `UiClass` — plain wiring (provide/scene/Composition) accepts either.
|
|
36
27
|
acceptsAny(LocalUi)
|
|
37
28
|
acceptsAny(WiredUiClass)
|
|
38
29
|
|
|
39
|
-
// POSITIVE: the schema form is a `WiredUi` — the remote seam accepts it.
|
|
40
30
|
acceptsWired(WiredUiClass)
|
|
41
31
|
|
|
42
|
-
// NEGATIVE: a type-only contract has no wire schema, so the remote seam rejects it.
|
|
43
32
|
// @ts-expect-error -- LocalUi is `UiClass`, not `WiredUi`
|
|
44
33
|
acceptsWired(LocalUi)
|
|
45
34
|
|
|
46
|
-
// POSITIVE: props/event types are DERIVED from the schemas — authoring a presentation
|
|
47
|
-
// against the wired contract sees `n: number` and `bump: Trigger<{ by: number }>`,
|
|
48
|
-
// none of it written a second time.
|
|
49
35
|
export const derivedView: MadeView<Contract<typeof WiredUiClass>> = make(
|
|
50
36
|
WiredUiClass,
|
|
51
37
|
({ n: count }, _slots, { bump }) => {
|