@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/src/compose/structure.ts
CHANGED
|
@@ -1,125 +1,161 @@
|
|
|
1
|
-
import { Struct } from 'effect'
|
|
1
|
+
import { Option, Struct } from 'effect'
|
|
2
|
+
import { reuse } from '../internal/reuse'
|
|
2
3
|
import type { SlotProps } from './slot'
|
|
3
4
|
import type { EventsOf, UiContract } from './ui'
|
|
4
5
|
|
|
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
6
|
export interface EachFill<P> {
|
|
21
7
|
readonly _tag: 'Each'
|
|
22
8
|
readonly items: ReadonlyArray<{ readonly key: string; readonly props: P }>
|
|
23
9
|
}
|
|
24
10
|
|
|
25
|
-
/** A singleton slot fill — the slot renders exactly one child with these props. */
|
|
26
11
|
export interface OneFill<P> {
|
|
27
12
|
readonly _tag: 'One'
|
|
28
13
|
readonly props: P
|
|
29
14
|
}
|
|
30
15
|
|
|
31
|
-
/** A slot that renders nothing this frame (the data-driven analog of `cond && …`). */
|
|
32
16
|
export interface AbsentFill {
|
|
33
17
|
readonly _tag: 'Absent'
|
|
34
18
|
}
|
|
35
19
|
|
|
36
|
-
/** How one declared slot is filled this frame, typed by the child's external props. */
|
|
37
20
|
export type SlotFill<P> = EachFill<P> | OneFill<P> | AbsentFill
|
|
38
21
|
|
|
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
22
|
export type StructureSlots<C extends UiContract> = C extends {
|
|
43
23
|
slots: infer S
|
|
44
24
|
}
|
|
45
25
|
? { readonly [K in keyof S]: SlotFill<SlotProps<S[K]>> }
|
|
46
|
-
: Record<
|
|
47
|
-
|
|
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
|
-
*/
|
|
63
|
-
export interface Structure<C extends UiContract> {
|
|
26
|
+
: Readonly<Record<PropertyKey, never>>
|
|
27
|
+
|
|
28
|
+
interface SerializableFrame<C extends UiContract> {
|
|
64
29
|
readonly props: C['props']
|
|
65
30
|
readonly slots: StructureSlots<C>
|
|
66
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- serializable frame shape; triggers ride optionally and the field is intentionally omitted from structureEquals
|
|
67
|
-
readonly events?: EventsOf<C>
|
|
68
31
|
}
|
|
69
32
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
33
|
+
interface TriggeredFrame<C extends UiContract> extends SerializableFrame<C> {
|
|
34
|
+
readonly events: EventsOf<C>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type MountFrame<C extends UiContract> = SerializableFrame<C> | TriggeredFrame<C>
|
|
38
|
+
|
|
73
39
|
export const StructureTypeId: unique symbol = Symbol.for('reform/Structure')
|
|
74
40
|
export type StructureTypeId = typeof StructureTypeId
|
|
75
41
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
42
|
+
export interface Structure<C extends UiContract> {
|
|
43
|
+
readonly [StructureTypeId]: StructureTypeId
|
|
44
|
+
readonly props: C['props']
|
|
45
|
+
readonly slots: StructureSlots<C>
|
|
46
|
+
readonly events: EventsOf<C> | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const frameEvents = <C extends UiContract>(frame: MountFrame<C>): Option.Option<EventsOf<C>> =>
|
|
50
|
+
'events' in frame ? Option.some(frame.events) : Option.none<EventsOf<C>>()
|
|
51
|
+
|
|
52
|
+
export const mount = <C extends UiContract>(frame: MountFrame<C>): Structure<C> => ({
|
|
53
|
+
events: Option.getOrUndefined(frameEvents(frame)),
|
|
54
|
+
props: frame.props,
|
|
55
|
+
slots: frame.slots,
|
|
56
|
+
[StructureTypeId]: StructureTypeId,
|
|
57
|
+
})
|
|
58
|
+
|
|
93
59
|
export interface EachOptions<T, P> {
|
|
94
60
|
readonly key: (item: T) => string
|
|
95
61
|
readonly props: (item: T) => P
|
|
96
62
|
}
|
|
97
63
|
|
|
98
|
-
|
|
64
|
+
interface EachRetention {
|
|
65
|
+
readonly elements: ReadonlyArray<unknown>
|
|
66
|
+
readonly fill: unknown
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface CollectionRetentions {
|
|
70
|
+
readonly byProps: WeakMap<object, WeakMap<object, EachRetention>>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Stable collection and callback identities retain unchanged fills by reference.
|
|
74
|
+
const eachRetentions = new WeakMap<object, CollectionRetentions>()
|
|
75
|
+
const latestEachRetentions = new WeakMap<object, EachRetention>()
|
|
76
|
+
|
|
77
|
+
const hasIdentity = (candidate: unknown): candidate is object =>
|
|
78
|
+
(typeof candidate === 'object' && candidate !== null) || typeof candidate === 'function'
|
|
79
|
+
|
|
80
|
+
const collectionRetentionsFor = (collection: unknown): CollectionRetentions | undefined => {
|
|
81
|
+
if (!hasIdentity(collection)) {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
return (
|
|
85
|
+
eachRetentions.get(collection) ?? {
|
|
86
|
+
byProps: new WeakMap<object, WeakMap<object, EachRetention>>(),
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sameElements = (previous: ReadonlyArray<unknown>, next: ReadonlyArray<unknown>): boolean =>
|
|
92
|
+
previous.length === next.length &&
|
|
93
|
+
previous.every((element, index) => Object.is(element, next[index]))
|
|
94
|
+
|
|
95
|
+
const isEachFill = <P>(candidate: unknown): candidate is EachFill<P> =>
|
|
96
|
+
isRecord(candidate) &&
|
|
97
|
+
candidate['_tag'] === 'Each' &&
|
|
98
|
+
Array.isArray(candidate['items']) &&
|
|
99
|
+
candidate['items'].every(
|
|
100
|
+
(entry) => isRecord(entry) && typeof entry['key'] === 'string' && 'props' in entry,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const materializeEach = <T, P>(
|
|
104
|
+
elements: ReadonlyArray<T>,
|
|
105
|
+
options: EachOptions<T, P>,
|
|
106
|
+
): EachFill<P> => ({
|
|
99
107
|
_tag: 'Each',
|
|
100
|
-
items:
|
|
108
|
+
items: elements.map((element) => ({
|
|
101
109
|
key: options.key(element),
|
|
102
110
|
props: options.props(element),
|
|
103
111
|
})),
|
|
104
112
|
})
|
|
105
113
|
|
|
106
|
-
|
|
114
|
+
const retainEach = <P>(previous: unknown, next: EachFill<P>): EachFill<P> => {
|
|
115
|
+
if (!isEachFill<P>(previous)) {
|
|
116
|
+
return next
|
|
117
|
+
}
|
|
118
|
+
if (deepEquals({ left: previous, right: next })) {
|
|
119
|
+
return previous
|
|
120
|
+
}
|
|
121
|
+
return reuse(previous, next)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const each = <T, P>(collection: Iterable<T>, options: EachOptions<T, P>): EachFill<P> => {
|
|
125
|
+
const elements = Array.from(collection)
|
|
126
|
+
const collectionRetentions = collectionRetentionsFor(collection)
|
|
127
|
+
const retained = collectionRetentions?.byProps.get(options.props)?.get(options.key)
|
|
128
|
+
const latest = hasIdentity(collection) ? latestEachRetentions.get(collection) : undefined
|
|
129
|
+
if (
|
|
130
|
+
retained !== undefined &&
|
|
131
|
+
sameElements(retained.elements, elements) &&
|
|
132
|
+
isEachFill<P>(retained.fill)
|
|
133
|
+
) {
|
|
134
|
+
return retained.fill
|
|
135
|
+
}
|
|
136
|
+
const fill = retainEach(retained?.fill ?? latest?.fill, materializeEach(elements, options))
|
|
137
|
+
if (hasIdentity(collection) && collectionRetentions !== undefined) {
|
|
138
|
+
const byKey =
|
|
139
|
+
collectionRetentions.byProps.get(options.props) ?? new WeakMap<object, EachRetention>()
|
|
140
|
+
const retention = { elements, fill }
|
|
141
|
+
byKey.set(options.key, retention)
|
|
142
|
+
collectionRetentions.byProps.set(options.props, byKey)
|
|
143
|
+
eachRetentions.set(collection, collectionRetentions)
|
|
144
|
+
latestEachRetentions.set(collection, retention)
|
|
145
|
+
}
|
|
146
|
+
return fill
|
|
147
|
+
}
|
|
148
|
+
|
|
107
149
|
export const one = <P>(props: P): OneFill<P> => ({ _tag: 'One', props })
|
|
108
150
|
|
|
109
|
-
/** The shared empty fill — a slot that renders nothing this frame. */
|
|
110
151
|
export const absent: AbsentFill = { _tag: 'Absent' }
|
|
111
152
|
|
|
112
|
-
/** Conditional presence: the fill when `cond`, otherwise {@link absent}. */
|
|
113
153
|
export const when = <F extends SlotFill<unknown>>(cond: boolean, fill: F): F | AbsentFill =>
|
|
114
154
|
cond ? fill : absent
|
|
115
155
|
|
|
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
156
|
export const isStructure = (frame: unknown): frame is Structure<UiContract> =>
|
|
119
157
|
typeof frame === 'object' && frame !== null && StructureTypeId in frame
|
|
120
158
|
|
|
121
|
-
/** Whether a value is a plain record (not an array) — narrows for the deep compare
|
|
122
|
-
* below without a cast. */
|
|
123
159
|
const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
124
160
|
typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
|
|
125
161
|
|
|
@@ -128,8 +164,6 @@ interface ValuePair {
|
|
|
128
164
|
readonly right: unknown
|
|
129
165
|
}
|
|
130
166
|
|
|
131
|
-
/** Minimal structural deep-equality for serializable shapes (plain JSON — the
|
|
132
|
-
* UI-contract guarantee). Avoids a heavyweight dep for the settle comparison. */
|
|
133
167
|
const deepEquals = ({ left, right }: ValuePair): boolean => {
|
|
134
168
|
if (Object.is(left, right)) {
|
|
135
169
|
return true
|
|
@@ -155,15 +189,10 @@ const deepEquals = ({ left, right }: ValuePair): boolean => {
|
|
|
155
189
|
return false
|
|
156
190
|
}
|
|
157
191
|
|
|
158
|
-
/** Structural equality over two structures — props and every slot fill compared by
|
|
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. */
|
|
164
192
|
export const structureEquals = (
|
|
165
193
|
left: Structure<UiContract>,
|
|
166
194
|
right: Structure<UiContract>,
|
|
167
195
|
): boolean =>
|
|
196
|
+
// Events are fresh trigger functions, so equality covers serializable frame data only.
|
|
168
197
|
deepEquals({ left: left.props, right: right.props }) &&
|
|
169
198
|
deepEquals({ left: left.slots, right: right.slots })
|
package/src/compose/ui.test.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import { expect, test } from 'vitest'
|
|
2
|
-
import { Schema as S } from 'effect'
|
|
3
|
-
import { isUi, ui } from '../index'
|
|
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.
|
|
1
|
+
import { expect, it, test } from '@effect/vitest'
|
|
2
|
+
import { Effect, Layer, Schema as S } from 'effect'
|
|
3
|
+
import { isUi, provide, Ui, ui } from '../index'
|
|
8
4
|
|
|
9
5
|
test('type-only `ui` carries no wire schema', () => {
|
|
10
6
|
class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
|
|
@@ -32,3 +28,19 @@ test('schema `ui` with no events omits the events schema', () => {
|
|
|
32
28
|
expect(S.isSchema(TitleUi.manifest.props)).toBe(true)
|
|
33
29
|
expect(TitleUi.manifest.events).toBeUndefined()
|
|
34
30
|
})
|
|
31
|
+
|
|
32
|
+
it.live('same-shaped UI definitions resolve their own views', () => {
|
|
33
|
+
class FirstUi extends ui('UiNominalFirst')<{ props: { label: string } }>() {}
|
|
34
|
+
class SecondUi extends ui('UiNominalSecond')<{ props: { label: string } }>() {}
|
|
35
|
+
const FirstView = Ui.make(FirstUi, ({ label }) => `first:${label}`)
|
|
36
|
+
const SecondView = Ui.make(SecondUi, ({ label }) => `second:${label}`)
|
|
37
|
+
const Live = Layer.mergeAll(provide(FirstUi, FirstView), provide(SecondUi, SecondView))
|
|
38
|
+
|
|
39
|
+
return Effect.gen(function* () {
|
|
40
|
+
const first = yield* FirstUi.impl
|
|
41
|
+
const second = yield* SecondUi.impl
|
|
42
|
+
|
|
43
|
+
expect(first({ label: 'value' }, {}, {})).toBe('first:value')
|
|
44
|
+
expect(second({ label: 'value' }, {}, {})).toBe('second:value')
|
|
45
|
+
}).pipe(Effect.provide(Live))
|
|
46
|
+
})
|