@playfast/reform 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/boundary/boundary.ts +16 -7
- package/src/calc/asyncCalc.invalidate.test.ts +166 -0
- package/src/calc/asyncCalc.ts +116 -25
- package/src/calc/asyncData.ts +8 -4
- package/src/calc/calc.ts +7 -3
- package/src/calc/calcFamily.ts +24 -10
- package/src/calc/compose.ts +1 -1
- package/src/calc/queryState.ts +52 -0
- package/src/channel/channel.ts +63 -37
- package/src/compose/composition.ts +20 -1
- package/src/compose/provide.ts +5 -1
- package/src/compose/slot.ts +4 -2
- package/src/compose/structure.ts +43 -19
- package/src/compose/ui.ts +21 -2
- package/src/compose/ui.typecheck.ts +4 -4
- package/src/definition/definition.ts +20 -7
- package/src/feature/feature.test.ts +4 -4
- package/src/feature/feature.ts +30 -16
- package/src/feature/feature.typecheck.ts +2 -2
- package/src/index.ts +21 -0
- package/src/internal/capture.ts +1 -0
- package/src/internal/errors.ts +9 -4
- package/src/internal/inspect.ts +4 -4
- package/src/internal/queryDriver.ts +273 -86
- package/src/internal/queryEvents.ts +34 -0
- package/src/internal/queryStore.ts +36 -0
- package/src/internal/reuse.ts +67 -30
- package/src/internal/scheduler.ts +35 -23
- package/src/internal/sources.ts +14 -8
- package/src/internal/stateRegistry.ts +3 -1
- package/src/internal/store.ts +10 -8
- package/src/internal/track.ts +3 -1
- package/src/procedure/procedure.ts +2 -2
- package/src/reducer/reducer.ts +17 -9
- package/src/remote/remoteState.test.ts +188 -1
- package/src/remote/remoteState.ts +112 -51
- package/src/remote/remoteState.typecheck.ts +4 -1
- package/src/runtime/bus.ts +3 -1
- package/src/runtime/hardening.test.ts +1 -1
- package/src/runtime/loop.ts +81 -53
- package/src/runtime/queries.ts +55 -0
- package/src/scene/scene.ts +16 -8
- package/src/state/state.ts +11 -10
- package/src/state/stateFamily.ts +27 -11
- package/src/state/stateGroup.ts +22 -9
- package/src/synced/syncedStore.ts +15 -9
- package/src/wire/tree.ts +66 -30
- package/src/wire/triggers.ts +9 -8
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Option } from 'effect'
|
|
2
|
+
import { type AnyAsyncData, AsyncData } from './asyncData'
|
|
3
|
+
|
|
4
|
+
// `QueryState` is the data-oriented value an `AsyncCalc` is driven by: the four
|
|
5
|
+
// axes of a query, stored *flat and independent* so each can move on its own.
|
|
6
|
+
// `data`/`error` survive a refetch (stale-while-revalidate for free); `isFetching`
|
|
7
|
+
// (activity) and `isStale` (freshness) are orthogonal to both — which is exactly
|
|
8
|
+
// what lets `invalidate` (flip `isStale`) and `refetch` (drive `isFetching`) be
|
|
9
|
+
// separate operations. The matchable `AsyncData` union is *derived* from this
|
|
10
|
+
// (`toAsyncData`), so view code and `RemoteState` keep their arm dispatch.
|
|
11
|
+
|
|
12
|
+
export interface QueryState<A, E> {
|
|
13
|
+
/** Last resolved value — kept across a refetch. */
|
|
14
|
+
readonly data: Option.Option<A>
|
|
15
|
+
/** Last failure — kept across a refetch. */
|
|
16
|
+
readonly error: Option.Option<E>
|
|
17
|
+
/** Activity axis: a run is in flight. */
|
|
18
|
+
readonly isFetching: boolean
|
|
19
|
+
/** Freshness axis: the value has been marked invalid (drives auto-refetch). */
|
|
20
|
+
readonly isStale: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The empty cell. `fetching` seeds the first-kick `Loading` for an enabled query. */
|
|
24
|
+
export const empty = (fetching: boolean): QueryState<never, never> => ({
|
|
25
|
+
data: Option.none(),
|
|
26
|
+
error: Option.none(),
|
|
27
|
+
isFetching: fetching,
|
|
28
|
+
isStale: false,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Project the flat state onto the public `AsyncData` union (the view contract) —
|
|
33
|
+
* the single `Match`-able view of a query, so call sites keep `Match.tag`-ing the
|
|
34
|
+
* arms. `error` wins over `data` so a failed refetch surfaces the `Error` arm,
|
|
35
|
+
* matching the pre-`QueryState` driver; `isFetching` becomes the arm's
|
|
36
|
+
* `refetching` flag. The data-less case is `Loading` while fetching (or for a
|
|
37
|
+
* non-gated query, which always fetches) and `Idle` only for a switched-off
|
|
38
|
+
* gateable query.
|
|
39
|
+
*/
|
|
40
|
+
export const toAsyncData = <A, E>(state: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> =>
|
|
41
|
+
Option.match(state.error, {
|
|
42
|
+
onSome: (error) => AsyncData.error(error, state.isFetching),
|
|
43
|
+
onNone: () =>
|
|
44
|
+
Option.match(state.data, {
|
|
45
|
+
onSome: (payload) => AsyncData.success(payload, state.isFetching),
|
|
46
|
+
onNone: () => (state.isFetching || !gated ? AsyncData.loading : AsyncData.idle),
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** `Loading` in the React-Query sense: fetching with nothing to show yet. */
|
|
51
|
+
export const isLoading = <A, E>(state: QueryState<A, E>): boolean =>
|
|
52
|
+
state.isFetching && Option.isNone(state.data)
|
package/src/channel/channel.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { Chunk, Context, type Duration, Effect, Layer, Match, Queue, Stream } from 'effect'
|
|
1
|
+
import { Chunk, Context, type Duration, Effect, Layer, Match, Option, Queue, Stream } from 'effect'
|
|
2
2
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
|
-
import { DuplicateRegistration } from '../internal/errors'
|
|
4
3
|
import type { Tagged } from '../runtime/bus'
|
|
5
4
|
|
|
6
5
|
/**
|
|
@@ -22,9 +21,9 @@ export type ChannelPolicy =
|
|
|
22
21
|
readonly _tag: 'throttle'
|
|
23
22
|
readonly units: number
|
|
24
23
|
readonly duration: Duration.DurationInput
|
|
25
|
-
readonly cost
|
|
26
|
-
readonly burst
|
|
27
|
-
readonly strategy
|
|
24
|
+
readonly cost: Option.Option<number>
|
|
25
|
+
readonly burst: Option.Option<number>
|
|
26
|
+
readonly strategy: Option.Option<'enforce' | 'shape'>
|
|
28
27
|
}
|
|
29
28
|
| { readonly _tag: 'exclusive' }
|
|
30
29
|
|
|
@@ -91,56 +90,71 @@ export class Procedures extends ProceduresBase {}
|
|
|
91
90
|
/** Get a map entry, creating and inserting it on first access (avoids `let`). */
|
|
92
91
|
const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
|
|
93
92
|
const existing = map.get(key)
|
|
94
|
-
if (existing !== undefined)
|
|
93
|
+
if (existing !== undefined) {
|
|
94
|
+
return existing
|
|
95
|
+
}
|
|
95
96
|
const created = make()
|
|
96
97
|
map.set(key, created)
|
|
97
98
|
return created
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/** Drop an item from a `Map<K, Array>` bucket, pruning the key when it empties. */
|
|
101
|
-
const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K,
|
|
102
|
+
const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K, target: T): void => {
|
|
102
103
|
const bucket = map.get(key)
|
|
103
|
-
if (bucket === undefined)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
if (bucket === undefined) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const next = bucket.filter((candidate) => candidate !== target)
|
|
108
|
+
if (next.length === 0) {
|
|
109
|
+
map.delete(key)
|
|
110
|
+
} else {
|
|
111
|
+
map.set(key, next)
|
|
112
|
+
}
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
const makeProcedureRegistry = (): ProcedureRegistry => {
|
|
110
|
-
|
|
116
|
+
// `entries` is reassigned immutably (new array per change) rather than mutated
|
|
117
|
+
// in place, so the holder keeps a stable reference the getter reads through.
|
|
118
|
+
const state: { entries: Array<ProcedureEntry> } = { entries: [] }
|
|
111
119
|
const byTag = new Map<string, Array<ProcedureEntry>>()
|
|
112
120
|
const channelsByTag = new Map<string, Array<string>>()
|
|
113
121
|
const byChannelTag = new Map<string, Map<string, Array<ProcedureEntry>>>()
|
|
114
122
|
return {
|
|
115
|
-
entries
|
|
123
|
+
get entries() {
|
|
124
|
+
return state.entries
|
|
125
|
+
},
|
|
116
126
|
byTag,
|
|
117
127
|
channelsByTag,
|
|
118
128
|
byChannelTag,
|
|
119
129
|
register: (entry) => {
|
|
120
|
-
entries.
|
|
121
|
-
|
|
122
|
-
getOrCreate(byTag, tag, () => [])
|
|
130
|
+
state.entries = [...state.entries, entry]
|
|
131
|
+
entry.handles.forEach((tag) => {
|
|
132
|
+
byTag.set(tag, [...getOrCreate(byTag, tag, () => []), entry])
|
|
123
133
|
const channels = getOrCreate(channelsByTag, tag, () => [])
|
|
124
|
-
if (!channels.includes(entry.channelName))
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
-
|
|
134
|
+
if (!channels.includes(entry.channelName)) {
|
|
135
|
+
channelsByTag.set(tag, [...channels, entry.channelName])
|
|
136
|
+
}
|
|
137
|
+
const channelMap = getOrCreate(byChannelTag, entry.channelName, () => new Map())
|
|
138
|
+
channelMap.set(tag, [...getOrCreate(channelMap, tag, () => []), entry])
|
|
139
|
+
})
|
|
129
140
|
},
|
|
130
141
|
unregister: (entry) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
for (const tag of entry.handles) {
|
|
142
|
+
state.entries = state.entries.filter((existing) => existing !== entry)
|
|
143
|
+
entry.handles.forEach((tag) => {
|
|
134
144
|
dropFrom(byTag, tag, entry)
|
|
135
145
|
const channelMap = byChannelTag.get(entry.channelName)
|
|
136
146
|
if (channelMap !== undefined) {
|
|
137
147
|
dropFrom(channelMap, tag, entry)
|
|
138
148
|
// If no procedure on this channel still handles the tag, drop the
|
|
139
149
|
// channel from `channelsByTag[tag]` so the loop stops offering to it.
|
|
140
|
-
if (channelMap.get(tag) === undefined)
|
|
141
|
-
|
|
150
|
+
if (channelMap.get(tag) === undefined) {
|
|
151
|
+
dropFrom(channelsByTag, tag, entry.channelName)
|
|
152
|
+
}
|
|
153
|
+
if (channelMap.size === 0) {
|
|
154
|
+
byChannelTag.delete(entry.channelName)
|
|
155
|
+
}
|
|
142
156
|
}
|
|
143
|
-
}
|
|
157
|
+
})
|
|
144
158
|
},
|
|
145
159
|
}
|
|
146
160
|
}
|
|
@@ -194,7 +208,11 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
194
208
|
// name is a silent footgun — the second policy would be dropped — so
|
|
195
209
|
// reject it loudly instead.
|
|
196
210
|
if (existing.policy !== channel.policy) {
|
|
197
|
-
|
|
211
|
+
// A different channel reusing a live name is a programmer footgun, not a
|
|
212
|
+
// recoverable failure — surface it as a defect to keep the layer E = never.
|
|
213
|
+
yield* Effect.dieMessage(
|
|
214
|
+
`reform: channel '${name}' is already live with a different policy`,
|
|
215
|
+
)
|
|
198
216
|
}
|
|
199
217
|
return
|
|
200
218
|
}
|
|
@@ -206,11 +224,15 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
206
224
|
// A procedure is expected to model expected failures as events; a leaked
|
|
207
225
|
// defect (a bug in the body) is logged before being isolated, so one
|
|
208
226
|
// procedure's crash never tears down the channel fiber — but is observable.
|
|
209
|
-
const runMatching = (event: Tagged): Effect.Effect<void> =>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
(
|
|
213
|
-
|
|
227
|
+
const runMatching = (event: Tagged): Effect.Effect<void> => {
|
|
228
|
+
const matching = Option.fromNullable(procedures.byChannelTag.get(name)).pipe(
|
|
229
|
+
Option.flatMapNullable((byTag) => byTag.get(event._tag)),
|
|
230
|
+
Option.getOrElse((): ReadonlyArray<ProcedureEntry> => []),
|
|
231
|
+
)
|
|
232
|
+
return Effect.forEach(
|
|
233
|
+
matching,
|
|
234
|
+
(procedure) =>
|
|
235
|
+
procedure.run(event).pipe(
|
|
214
236
|
Effect.tapErrorCause((cause) =>
|
|
215
237
|
Effect.logError(`reform: procedure on channel '${name}' failed`, cause),
|
|
216
238
|
),
|
|
@@ -218,6 +240,7 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
218
240
|
),
|
|
219
241
|
{ discard: true, concurrency: 'unbounded' },
|
|
220
242
|
)
|
|
243
|
+
}
|
|
221
244
|
|
|
222
245
|
// Map the policy to its scheduling stream. `Match.exhaustive` makes a new
|
|
223
246
|
// policy variant a compile error, and each arm is a self-contained const.
|
|
@@ -225,7 +248,7 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
225
248
|
const driven: Stream.Stream<unknown> = Match.value(channel.policy).pipe(
|
|
226
249
|
Match.tag('merge', () => Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' })),
|
|
227
250
|
Match.tag('latest', () =>
|
|
228
|
-
Stream.flatMap(events, (
|
|
251
|
+
Stream.flatMap(events, (event) => Stream.fromEffect(runMatching(event)), { switch: true }),
|
|
229
252
|
),
|
|
230
253
|
Match.tag('debounce', (policy) =>
|
|
231
254
|
events.pipe(
|
|
@@ -236,11 +259,14 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
|
|
|
236
259
|
Match.tag('throttle', (policy) =>
|
|
237
260
|
events.pipe(
|
|
238
261
|
Stream.throttle({
|
|
239
|
-
cost: (chunk) => Chunk.size(chunk) * (policy.cost
|
|
262
|
+
cost: (chunk) => Chunk.size(chunk) * Option.getOrElse(policy.cost, () => 1),
|
|
240
263
|
units: policy.units,
|
|
241
264
|
duration: policy.duration,
|
|
242
|
-
...(policy.burst
|
|
243
|
-
|
|
265
|
+
...Option.match(policy.burst, {
|
|
266
|
+
onNone: () => ({}),
|
|
267
|
+
onSome: (burst) => ({ burst }),
|
|
268
|
+
}),
|
|
269
|
+
strategy: Option.getOrElse(policy.strategy, () => 'shape' as const),
|
|
244
270
|
}),
|
|
245
271
|
Stream.mapEffect(runMatching, { concurrency: 'unbounded' }),
|
|
246
272
|
),
|
|
@@ -30,15 +30,26 @@ export interface CompositionService {
|
|
|
30
30
|
readonly render: (env: RenderEnv) => Effect.Effect<Frame, never, never>
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Reflectable manifest/config carrier: only `title` + `ui` are required, and the
|
|
34
|
+
// optional members drive `Config extends { props: … }` / `{ states: … }` inference
|
|
35
|
+
// (PropsOf/StatesOf) plus the ergonomic `Composition.make({ title, ui })` form, so
|
|
36
|
+
// they cannot become `Option<T>` (would break inference and every call site) or be
|
|
37
|
+
// renamed `ExternalApi` (would break external importers of `CompositionConfig`).
|
|
33
38
|
export interface CompositionConfig {
|
|
34
39
|
readonly title: string
|
|
40
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
|
|
35
41
|
readonly description?: string
|
|
36
42
|
// `any` in the encoded/context slots is required by Schema's variance for
|
|
37
43
|
// branded/Class schemas (e.g. `Todo`); only the decoded type `P` is used.
|
|
44
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- drives `Config extends { props: … }` inference (PropsOf)
|
|
38
45
|
readonly props?: Schema.Schema<any, any>
|
|
46
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- drives `Config extends { states: … }` inference (StatesOf)
|
|
39
47
|
readonly states?: ReadonlyArray<unknown>
|
|
48
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
|
|
40
49
|
readonly calcs?: ReadonlyArray<unknown>
|
|
50
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
|
|
41
51
|
readonly events?: ReadonlyArray<unknown>
|
|
52
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
|
|
42
53
|
readonly slots?: Record<string, SlotClass>
|
|
43
54
|
// The UI contract this composition resolves. Typed as a manifest carrier (not
|
|
44
55
|
// the full `UiClass`, whose invariant `impl` tag would force the contract type
|
|
@@ -87,12 +98,19 @@ export interface CompositionClass<
|
|
|
87
98
|
* (`Composition.live`) is the synchronous body, provided separately and
|
|
88
99
|
* checked against it.
|
|
89
100
|
*/
|
|
101
|
+
/** The contract-carrying overlay on a composition config — keeps `C` inferable at
|
|
102
|
+
* the `Composition.make` call site while the stored manifest sees only the erased
|
|
103
|
+
* `{ manifest }` carrier. */
|
|
104
|
+
interface UiCarrier<C extends UiContract> {
|
|
105
|
+
readonly ui: UiClass<C>
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
export const make = <const Config extends CompositionConfig, C extends UiContract>(
|
|
91
109
|
name: string,
|
|
92
110
|
// Constrain `ui` to the full `UiClass<C>` so `C` is inferred here; the stored
|
|
93
111
|
// manifest still sees only the erased `{ manifest }` carrier (CompositionConfig),
|
|
94
112
|
// so the manifest type — and its variance — is unchanged.
|
|
95
|
-
config: Config &
|
|
113
|
+
config: Config & UiCarrier<C>,
|
|
96
114
|
): CompositionClass<PropsOf<Config>, C, StatesOf<Config>> => {
|
|
97
115
|
const tag = Context.GenericTag<CompositionService, CompositionService>(
|
|
98
116
|
`reform/composition/${name}`,
|
|
@@ -131,6 +149,7 @@ export const live = <
|
|
|
131
149
|
// widens the error channel to the body's inferred `E` for a generic body, so
|
|
132
150
|
// narrowing it (and the contract) back to the erased service type needs the
|
|
133
151
|
// `unknown` bridge — the one cast this render boundary has always required.
|
|
152
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- the one documented render-boundary erasure: a fully-provided generic body's Structure<C> narrowed back to the contract-agnostic Frame service type
|
|
134
153
|
logic.pipe(
|
|
135
154
|
Effect.provideService(Props, env.props),
|
|
136
155
|
Effect.provideService(CurrentTracker, env.tracker),
|
package/src/compose/provide.ts
CHANGED
|
@@ -45,16 +45,20 @@ export function provide<
|
|
|
45
45
|
Requires
|
|
46
46
|
>,
|
|
47
47
|
): Layer.Layer<SlotChild, never, EngineServices | ProvidedBy<Requires>>
|
|
48
|
+
// 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
|
|
48
49
|
export function provide(target: unknown, impl: unknown): Layer.Layer<never, never, unknown> {
|
|
49
50
|
// Discriminate the target by its nominal brand, not by which DI-hole property
|
|
50
51
|
// happens to exist: a UI contract provides under its `impl` tag, a slot under
|
|
51
52
|
// its `tag`. Anything else is a wiring mistake. The overloads above pair
|
|
52
53
|
// `impl` with the chosen tag; this implementation sees both erased, so view
|
|
53
54
|
// the tag loosely (the one documented cast) to provide the service under it.
|
|
55
|
+
// 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
|
|
54
56
|
const tag = (isUi(target) ? target.impl : isSlot(target) ? target.tag : undefined) as
|
|
55
57
|
| Context.Tag<unknown, unknown>
|
|
56
58
|
| undefined
|
|
57
|
-
if (tag === undefined)
|
|
59
|
+
if (tag === undefined) {
|
|
60
|
+
return Layer.die(new InvalidProvideTarget())
|
|
61
|
+
}
|
|
58
62
|
if (isFeature(impl)) {
|
|
59
63
|
const eager = impl.eagerModule
|
|
60
64
|
// Lazy: bind the `FeatureBinding`; the host drives load → mount, painting the
|
package/src/compose/slot.ts
CHANGED
|
@@ -50,8 +50,10 @@ export interface SlotClass<Comp extends AnyComposition = AnyComposition> {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Whether a value is a slot — discriminates a `provide` target by brand. */
|
|
53
|
-
export const isSlot = (
|
|
54
|
-
(typeof
|
|
53
|
+
export const isSlot = (candidate: unknown): candidate is SlotClass =>
|
|
54
|
+
(typeof candidate === 'function' || typeof candidate === 'object') &&
|
|
55
|
+
candidate !== null &&
|
|
56
|
+
SlotTypeId in candidate
|
|
55
57
|
|
|
56
58
|
/** The props a slot renders with — the child composition's external props. */
|
|
57
59
|
export type SlotProps<S> = S extends SlotInstance<infer Comp> ? Comp['Props'] : never
|
package/src/compose/structure.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Struct } from 'effect'
|
|
1
2
|
import type { SlotProps } from './slot'
|
|
2
3
|
import type { EventsOf, UiContract } from './ui'
|
|
3
4
|
|
|
@@ -62,6 +63,7 @@ export type StructureSlots<C extends UiContract> = C extends {
|
|
|
62
63
|
export interface Structure<C extends UiContract> {
|
|
63
64
|
readonly props: C['props']
|
|
64
65
|
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
|
|
65
67
|
readonly events?: EventsOf<C>
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -80,6 +82,7 @@ export type StructureTypeId = typeof StructureTypeId
|
|
|
80
82
|
* empty map when omitted, so a frame always carries an `events` record for hosts.
|
|
81
83
|
*/
|
|
82
84
|
export const mount = <C extends UiContract>(structure: Structure<C>): Structure<C> =>
|
|
85
|
+
// 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
|
|
83
86
|
Object.assign({ [StructureTypeId]: StructureTypeId, events: {} }, structure)
|
|
84
87
|
|
|
85
88
|
/**
|
|
@@ -87,12 +90,17 @@ export const mount = <C extends UiContract>(structure: Structure<C>): Structure<
|
|
|
87
90
|
* the per-item state family — passing the same id source to both is no longer
|
|
88
91
|
* possible to get wrong. `props` maps each item to the child's external props.
|
|
89
92
|
*/
|
|
90
|
-
export
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
export interface EachOptions<T, P> {
|
|
94
|
+
readonly key: (item: T) => string
|
|
95
|
+
readonly props: (item: T) => P
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const each = <T, P>(collection: Iterable<T>, options: EachOptions<T, P>): EachFill<P> => ({
|
|
94
99
|
_tag: 'Each',
|
|
95
|
-
items: Array.from(collection, (
|
|
100
|
+
items: Array.from(collection, (element) => ({
|
|
101
|
+
key: options.key(element),
|
|
102
|
+
props: options.props(element),
|
|
103
|
+
})),
|
|
96
104
|
})
|
|
97
105
|
|
|
98
106
|
/** A singleton slot fill — render one child with these external props. */
|
|
@@ -112,23 +120,35 @@ export const isStructure = (frame: unknown): frame is Structure<UiContract> =>
|
|
|
112
120
|
|
|
113
121
|
/** Whether a value is a plain record (not an array) — narrows for the deep compare
|
|
114
122
|
* below without a cast. */
|
|
115
|
-
const isRecord = (
|
|
116
|
-
typeof
|
|
123
|
+
const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
124
|
+
typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
|
|
125
|
+
|
|
126
|
+
interface ValuePair {
|
|
127
|
+
readonly left: unknown
|
|
128
|
+
readonly right: unknown
|
|
129
|
+
}
|
|
117
130
|
|
|
118
131
|
/** Minimal structural deep-equality for serializable shapes (plain JSON — the
|
|
119
132
|
* UI-contract guarantee). Avoids a heavyweight dep for the settle comparison. */
|
|
120
|
-
const deepEquals = (
|
|
121
|
-
if (Object.is(
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
const deepEquals = ({ left, right }: ValuePair): boolean => {
|
|
134
|
+
if (Object.is(left, right)) {
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
138
|
+
return (
|
|
139
|
+
left.length === right.length &&
|
|
140
|
+
left.every((element, index) => deepEquals({ left: element, right: right[index] }))
|
|
141
|
+
)
|
|
124
142
|
}
|
|
125
|
-
if (isRecord(
|
|
126
|
-
const
|
|
127
|
-
const
|
|
143
|
+
if (isRecord(left) && isRecord(right)) {
|
|
144
|
+
const leftKeys = Struct.keys(left)
|
|
145
|
+
const rightKeys = Struct.keys(right)
|
|
128
146
|
return (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(key) =>
|
|
147
|
+
leftKeys.length === rightKeys.length &&
|
|
148
|
+
leftKeys.every(
|
|
149
|
+
(key) =>
|
|
150
|
+
Object.prototype.hasOwnProperty.call(right, key) &&
|
|
151
|
+
deepEquals({ left: left[key], right: right[key] }),
|
|
132
152
|
)
|
|
133
153
|
)
|
|
134
154
|
}
|
|
@@ -141,5 +161,9 @@ const deepEquals = (a: unknown, b: unknown): boolean => {
|
|
|
141
161
|
* break the settle fixpoint. The `StructureTypeId` brand is a symbol key, so
|
|
142
162
|
* `Object.keys` skips it. Used by the proof/server settle loop to detect a
|
|
143
163
|
* two-frame fixpoint. */
|
|
144
|
-
export const structureEquals = (
|
|
145
|
-
|
|
164
|
+
export const structureEquals = (
|
|
165
|
+
left: Structure<UiContract>,
|
|
166
|
+
right: Structure<UiContract>,
|
|
167
|
+
): boolean =>
|
|
168
|
+
deepEquals({ left: left.props, right: right.props }) &&
|
|
169
|
+
deepEquals({ left: left.slots, right: right.slots })
|
package/src/compose/ui.ts
CHANGED
|
@@ -6,7 +6,13 @@ import { type Node, type SlotClass, type SlotInstance, type SlotProps } from './
|
|
|
6
6
|
|
|
7
7
|
export interface UiContract {
|
|
8
8
|
props: unknown
|
|
9
|
+
// `slots`/`events` are optional so a local contract literal (`ui('X')<{ props }>()`)
|
|
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.
|
|
13
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` contract inference (see note above)
|
|
9
14
|
slots?: Record<string, SlotInstance<any>>
|
|
15
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` contract inference (see note above)
|
|
10
16
|
events?: Record<string, Trigger<any>>
|
|
11
17
|
}
|
|
12
18
|
|
|
@@ -42,6 +48,7 @@ type SlotsOf<C extends UiContract> = C extends { slots: infer S }
|
|
|
42
48
|
// (`slots.Item.props.length`, a header, layout decisions) and still render them
|
|
43
49
|
// (`<slots.Item/>`); it never DERIVES multiplicity — the engine already fixed it. Read-only.
|
|
44
50
|
readonly [K in keyof S]: FunctionComponent<
|
|
51
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- React/JSX component-prop shape (`<slots.Row slotKey={id} />`); singleton slots omit it, framework-handled
|
|
45
52
|
Partial<SlotProps<S[K]> & { readonly slotKey?: string }>
|
|
46
53
|
> & { readonly props: ReadonlyArray<SlotProps<S[K]>> }
|
|
47
54
|
}
|
|
@@ -67,7 +74,11 @@ export interface UiManifest extends Manifest {
|
|
|
67
74
|
readonly kind: 'Ui'
|
|
68
75
|
// `any` in the encoded/context slots is required by Schema's variance for
|
|
69
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.
|
|
79
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- wire manifest field present only on the wired form (see note above)
|
|
70
80
|
readonly props?: Schema.Schema<any, any>
|
|
81
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- wire manifest field present only on the wired form (see note above)
|
|
71
82
|
readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
|
|
72
83
|
}
|
|
73
84
|
|
|
@@ -108,10 +119,14 @@ export type WiredUi<C extends UiContract> = UiClass<C> & { readonly manifest: Wi
|
|
|
108
119
|
* props the client reads AND that renders slot children). */
|
|
109
120
|
export interface WireSchemas {
|
|
110
121
|
readonly props: Schema.Schema<any, any>
|
|
122
|
+
// `events`/`slots` optional so `Sch extends { events: … }` / `{ slots: … }`
|
|
123
|
+
// derives the contract's facade only when declared (DerivedEvents/DerivedSlots).
|
|
124
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` derivation (DerivedEvents)
|
|
111
125
|
readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
|
|
112
126
|
// Slots are declared with their slot CLASSES (`slot('X')<typeof Child>()`), the
|
|
113
127
|
// same values `Composition.make({ slots })` takes — NOT `SlotInstance`s. The
|
|
114
128
|
// contract's slot facade is derived from them below.
|
|
129
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` derivation (DerivedSlots)
|
|
115
130
|
readonly slots?: Readonly<Record<string, SlotClass>>
|
|
116
131
|
}
|
|
117
132
|
|
|
@@ -140,8 +155,10 @@ export type DerivedContract<Sch extends WireSchemas> = {
|
|
|
140
155
|
}
|
|
141
156
|
|
|
142
157
|
/** Whether a value is a UI contract — discriminates a `provide` target by brand. */
|
|
143
|
-
export const isUi = (
|
|
144
|
-
(typeof
|
|
158
|
+
export const isUi = (candidate: unknown): candidate is UiClass<UiContract> =>
|
|
159
|
+
(typeof candidate === 'function' || typeof candidate === 'object') &&
|
|
160
|
+
candidate !== null &&
|
|
161
|
+
UiTypeId in candidate
|
|
145
162
|
|
|
146
163
|
const buildUi = <C extends UiContract, M extends UiManifest>(
|
|
147
164
|
name: string,
|
|
@@ -172,6 +189,7 @@ const buildUi = <C extends UiContract, M extends UiManifest>(
|
|
|
172
189
|
*/
|
|
173
190
|
export function ui(name: string): <C extends UiContract>() => UiClass<C>
|
|
174
191
|
export function ui<const Sch extends WireSchemas>(name: string, schemas: Sch): WiredUi<DerivedContract<Sch>>
|
|
192
|
+
// 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
|
|
175
193
|
export function ui(name: string, schemas?: WireSchemas): unknown {
|
|
176
194
|
if (schemas === undefined) {
|
|
177
195
|
return <C extends UiContract>(): UiClass<C> => buildUi<C, UiManifest>(name, { kind: 'Ui', name })
|
|
@@ -201,4 +219,5 @@ export type MadeView<C extends UiContract> = ViewImpl<C> & { readonly [UiViewCon
|
|
|
201
219
|
* remote client presentation. Wire the result with `provide(contract, view)`.
|
|
202
220
|
*/
|
|
203
221
|
export const make = <C extends UiContract>(contract: UiClass<C>, view: ViewImpl<C>): MadeView<C> =>
|
|
222
|
+
// 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
|
|
204
223
|
Object.assign(view, { [UiViewContract]: contract })
|
|
@@ -48,9 +48,9 @@ acceptsWired(LocalUi)
|
|
|
48
48
|
// none of it written a second time.
|
|
49
49
|
export const derivedView: MadeView<Contract<typeof WiredUiClass>> = make(
|
|
50
50
|
WiredUiClass,
|
|
51
|
-
({ n }, _slots, { bump }) => {
|
|
52
|
-
const
|
|
53
|
-
bump({ by:
|
|
54
|
-
return `${
|
|
51
|
+
({ n: count }, _slots, { bump }) => {
|
|
52
|
+
const numeric: number = count
|
|
53
|
+
bump({ by: numeric })
|
|
54
|
+
return `${numeric}`
|
|
55
55
|
},
|
|
56
56
|
)
|
|
@@ -2,7 +2,8 @@ import { Effect } from 'effect'
|
|
|
2
2
|
import { attachInspectable } from '../internal/inspect'
|
|
3
3
|
|
|
4
4
|
/** A definition's `toJSON`: its reflectable manifest, else its raw statics. */
|
|
5
|
-
const describe = (statics: object): unknown =>
|
|
5
|
+
const describe = (statics: object): unknown =>
|
|
6
|
+
('manifest' in statics ? Reflect.get(statics, 'manifest') : undefined) ?? statics
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The reflectable descriptor every primitive carries (principle #6). The editor
|
|
@@ -54,12 +55,13 @@ export const yieldableClass = <A, E, R, Statics extends object>(
|
|
|
54
55
|
// class function (notably `name`, which a `Source`-shaped definition like a
|
|
55
56
|
// Calc carries) and `assign` would throw on those; and `ownKeys` (unlike
|
|
56
57
|
// `entries`) carries symbol-keyed statics — the `TypeId` brands.
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
Object.defineProperty(Base, key, { value, writable: true, enumerable: true, configurable: true })
|
|
60
|
-
}
|
|
58
|
+
Reflect.ownKeys(statics).forEach((key) => {
|
|
59
|
+
const staticValue = Reflect.get(statics, key)
|
|
60
|
+
Object.defineProperty(Base, key, { value: staticValue, writable: true, enumerable: true, configurable: true })
|
|
61
|
+
})
|
|
61
62
|
// Print like an Effect value (the manifest), not `[object Object]`.
|
|
62
63
|
attachInspectable(Base, () => describe(statics))
|
|
64
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- phantom-shaped static side cannot structurally satisfy the yieldable Effect & Statics type
|
|
63
65
|
return Base as never
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -72,5 +74,16 @@ export const yieldableClass = <A, E, R, Statics extends object>(
|
|
|
72
74
|
* interface. The one assertion to `Class` lives here, documented, instead of a
|
|
73
75
|
* scattered `as never` / `as unknown as` at every `*.make`.
|
|
74
76
|
*/
|
|
75
|
-
export const definitionClass = <Class>(statics: object): Class =>
|
|
76
|
-
|
|
77
|
+
export const definitionClass = <Class>(statics: object): Class => {
|
|
78
|
+
// `defineProperty` over `Reflect.ownKeys`, not `Object.assign`: a static can
|
|
79
|
+
// shadow a non-writable own property of the class function (notably `name`)
|
|
80
|
+
// and `assign` would throw, and `ownKeys` carries the symbol-keyed `TypeId`.
|
|
81
|
+
const Base = class {}
|
|
82
|
+
Reflect.ownKeys(statics).forEach((key) => {
|
|
83
|
+
const staticValue = Reflect.get(statics, key)
|
|
84
|
+
Object.defineProperty(Base, key, { value: staticValue, writable: true, enumerable: true, configurable: true })
|
|
85
|
+
})
|
|
86
|
+
attachInspectable(Base, () => describe(statics))
|
|
87
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- the one documented seam: phantom type-only fields mean the assembled class cannot structurally satisfy Class
|
|
88
|
+
return Base as unknown as Class
|
|
89
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Layer } from 'effect'
|
|
1
|
+
import { Effect, Layer, Option } from 'effect'
|
|
2
2
|
import { expect, test } from 'vitest'
|
|
3
3
|
import * as Composition from '../compose/composition'
|
|
4
4
|
import { ui } from '../compose/ui'
|
|
@@ -30,14 +30,14 @@ test('a lazy feature reflects as a Feature manifest with placeholders, no chunk
|
|
|
30
30
|
expect(LazyFeature.manifest.name).toBe('lazyFeature')
|
|
31
31
|
expect(LazyFeature.manifest.strategy).toBe('lazy')
|
|
32
32
|
expect(LazyFeature.manifest.composition.name).toBe('TestComp')
|
|
33
|
-
expect(LazyFeature.manifest.placeholder
|
|
34
|
-
expect(LazyFeature.manifest.placeholder
|
|
33
|
+
expect(Option.getOrThrow(LazyFeature.manifest.placeholder).loading.name).toBe('TestComp')
|
|
34
|
+
expect(Option.getOrThrow(LazyFeature.manifest.placeholder).failed.name).toBe('FailComp')
|
|
35
35
|
expect(LazyFeature.eagerModule).toBeUndefined()
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
test('an eager feature reflects as default strategy with its module available', () => {
|
|
39
39
|
expect(EagerFeature.manifest.strategy).toBe('default')
|
|
40
|
-
expect(EagerFeature.manifest.placeholder).
|
|
40
|
+
expect(Option.isNone(EagerFeature.manifest.placeholder)).toBe(true)
|
|
41
41
|
expect(EagerFeature.eagerModule).toBe(mod)
|
|
42
42
|
})
|
|
43
43
|
|