@playfast/reform 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.8",
4
+ "version": "0.0.10",
5
5
  "type": "module",
6
6
  "description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
7
7
  "keywords": [
@@ -0,0 +1,166 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
3
+ import { AsyncCalc, Engine, Queries, QueryStore, State, StateGroup } from '../index'
4
+
5
+ // The data-oriented half of AsyncCalc: `invalidate` flips `isStale` only (no
6
+ // fetch), `refetch` forces a run, a stale query with active readers (or a fresh
7
+ // subscriber) refetches, and `persist` hydrates + writes through an optional
8
+ // QueryStore. `isStale` is read off the `Queries` registry handle.
9
+
10
+ const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
11
+
12
+ const isStale = (name: string) =>
13
+ Effect.map(Queries, (q) => q.byName.get(name)?.snapshot().isStale ?? false)
14
+
15
+ // An in-memory QueryStore, so persistence is exercised with no DOM.
16
+ const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
17
+ const map = new Map<string, unknown>(Object.entries(seed))
18
+ const layer = Layer.succeed(QueryStore, {
19
+ get: (key) => Effect.sync(() => Option.fromNullable(map.has(key) ? map.get(key) : undefined)),
20
+ set: (key, value) => Effect.sync(() => void map.set(key, value)),
21
+ remove: (key) => Effect.sync(() => void map.delete(key)),
22
+ })
23
+ return { map, layer }
24
+ }
25
+
26
+ it.live('invalidate marks stale without fetching; a fresh subscriber then refetches', () => {
27
+ class Count extends State.make('count', S.Number) {}
28
+ class Inputs extends StateGroup.make(Count) {}
29
+ const runs = { n: 0 }
30
+ class Q extends AsyncCalc.make('Q', {
31
+ inputs: [StateGroup.select(Inputs, 'count')],
32
+ output: S.Number,
33
+ alwaysOn: true,
34
+ }) {}
35
+ const QLive = AsyncCalc.live(Q, {
36
+ query: ({ count }) =>
37
+ Effect.sync(() => {
38
+ runs.n += 1
39
+ return count
40
+ }),
41
+ })
42
+ const TestLayer = QLive.pipe(
43
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
44
+ Layer.provideMerge(Engine),
45
+ )
46
+
47
+ return Effect.gen(function* () {
48
+ const store = yield* Q.store
49
+ yield* tick()
50
+ expect(runs.n).toBe(1)
51
+
52
+ // No active readers: invalidate flips the flag but fetches nothing.
53
+ yield* AsyncCalc.invalidate(Q)
54
+ yield* tick()
55
+ expect(yield* isStale('Q')).toBe(true)
56
+ expect(runs.n).toBe(1)
57
+
58
+ // A reader arriving over a stale query (the resubscribe/mount analog) refetches.
59
+ store.subscribe(() => {})
60
+ yield* tick()
61
+ expect(runs.n).toBe(2)
62
+ expect(yield* isStale('Q')).toBe(false)
63
+ }).pipe(Effect.provide(TestLayer))
64
+ })
65
+
66
+ it.live('invalidate with an active reader auto-refetches immediately, keeping the value', () => {
67
+ class Count extends State.make('count', S.Number) {}
68
+ class Inputs extends StateGroup.make(Count) {}
69
+ const runs = { n: 0 }
70
+ class Q extends AsyncCalc.make('Q', {
71
+ inputs: [StateGroup.select(Inputs, 'count')],
72
+ output: S.Number,
73
+ alwaysOn: true,
74
+ }) {}
75
+ const QLive = AsyncCalc.live(Q, {
76
+ query: ({ count }) =>
77
+ Effect.sync(() => {
78
+ runs.n += 1
79
+ return count
80
+ }).pipe(Effect.delay(Duration.millis(20))),
81
+ })
82
+ const TestLayer = QLive.pipe(
83
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 7 })),
84
+ Layer.provideMerge(Engine),
85
+ )
86
+
87
+ return Effect.gen(function* () {
88
+ const store = yield* Q.store
89
+ store.subscribe(() => {}) // active reader
90
+ yield* tick(40)
91
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
92
+ expect(runs.n).toBe(1)
93
+
94
+ // Stale + active ⇒ refetch now; the last value stays visible (SWR).
95
+ yield* AsyncCalc.invalidate(Q)
96
+ yield* tick(5)
97
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
98
+ yield* tick(40)
99
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
100
+ expect(runs.n).toBe(2)
101
+ }).pipe(Effect.provide(TestLayer))
102
+ })
103
+
104
+ it.live('refetch forces a run regardless of staleness', () => {
105
+ class Count extends State.make('count', S.Number) {}
106
+ class Inputs extends StateGroup.make(Count) {}
107
+ const runs = { n: 0 }
108
+ class Q extends AsyncCalc.make('Q', {
109
+ inputs: [StateGroup.select(Inputs, 'count')],
110
+ output: S.Number,
111
+ alwaysOn: true,
112
+ }) {}
113
+ const QLive = AsyncCalc.live(Q, {
114
+ query: ({ count }) =>
115
+ Effect.sync(() => {
116
+ runs.n += 1
117
+ return count
118
+ }),
119
+ })
120
+ const TestLayer = QLive.pipe(
121
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 3 })),
122
+ Layer.provideMerge(Engine),
123
+ )
124
+
125
+ return Effect.gen(function* () {
126
+ yield* tick()
127
+ expect(runs.n).toBe(1)
128
+ expect(yield* isStale('Q')).toBe(false)
129
+
130
+ // Not stale, no key movement — refetch still runs.
131
+ yield* AsyncCalc.refetch(Q)
132
+ yield* tick()
133
+ expect(runs.n).toBe(2)
134
+ }).pipe(Effect.provide(TestLayer))
135
+ })
136
+
137
+ it.live('persist: hydrates from the QueryStore (stale) then writes the settled value through', () => {
138
+ class Count extends State.make('count', S.Number) {}
139
+ class Inputs extends StateGroup.make(Count) {}
140
+ const store = fakeQueryStore({ P: 99 }) // a previously-persisted value
141
+ class Q extends AsyncCalc.make('Q', {
142
+ inputs: [StateGroup.select(Inputs, 'count')],
143
+ output: S.Number,
144
+ alwaysOn: true,
145
+ }) {}
146
+ const QLive = AsyncCalc.live(Q, {
147
+ query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(25))),
148
+ persist: { key: 'P' },
149
+ })
150
+ const TestLayer = QLive.pipe(
151
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
152
+ Layer.provideMerge(store.layer),
153
+ Layer.provideMerge(Engine),
154
+ )
155
+
156
+ return Effect.gen(function* () {
157
+ const view = yield* Q.store
158
+ // The persisted value shows instantly, marked refetching (stale-while-revalidate).
159
+ expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
160
+
161
+ // The fresh fetch lands and is written through to the store.
162
+ yield* tick(45)
163
+ expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
164
+ expect(store.map.get('P')).toBe(10)
165
+ }).pipe(Effect.provide(TestLayer))
166
+ })
@@ -18,6 +18,7 @@ import { type Store } from '../internal/store'
18
18
  import { readTracked } from '../internal/track'
19
19
  import * as Reducer from '../reducer/reducer'
20
20
  import { Reducers } from '../runtime/loop'
21
+ import { Queries } from '../runtime/queries'
21
22
  import * as State from '../state/state'
22
23
  import { type AnySource } from '../state/token'
23
24
  import { type AsyncData, narrowStore } from './asyncData'
@@ -97,6 +98,15 @@ export type AsyncCalcLive<
97
98
  * `Calc`'s `reuse`; opt-in (one O(result) walk per settle).
98
99
  */
99
100
  readonly reuse?: boolean
101
+ /**
102
+ * Persist the `Success` value through an optional host `QueryStore` (e.g.
103
+ * `@playfast/reform-query-browser`'s localStorage layer): hydrate it on build
104
+ * so the value shows instantly (marked stale, so it still refetches) and write
105
+ * through on each settle. `true` keys it by the calc name; pass `{ key }` to
106
+ * override. Values (de)serialize through the calc's `output` schema. No host
107
+ * `QueryStore` in context ⇒ inert (no hard requirement added).
108
+ */
109
+ readonly persist?: boolean | { readonly key?: string }
100
110
  } & (Gated extends true
101
111
  ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
102
112
  : { readonly disabled?: never })
@@ -214,8 +224,20 @@ export function live<
214
224
  readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
215
225
  readonly coalesce?: 'switch' | 'trailing'
216
226
  readonly reuse?: boolean
227
+ readonly persist?: boolean | { readonly key?: string }
217
228
  }
218
229
 
230
+ // `persist: true` keys by the calc name; `{ key }` overrides. The driver
231
+ // (de)serializes through the calc's own `output` schema.
232
+ const persist =
233
+ cfg.persist === undefined || cfg.persist === false
234
+ ? undefined
235
+ : {
236
+ key:
237
+ cfg.persist === true ? calc.manifest.name : cfg.persist.key ?? calc.manifest.name,
238
+ schema: calc.manifest.output,
239
+ }
240
+
219
241
  // The hidden revision store, read requirement-free (`serviceOption`): the
220
242
  // assembly below always provides it alongside this driver, and feeding it
221
243
  // through `extraKey` (not `wireSources`) keeps it out of the snapshot —
@@ -236,6 +258,7 @@ export function live<
236
258
  disabled: cfg.disabled,
237
259
  coalesce: cfg.coalesce,
238
260
  reuse: cfg.reuse,
261
+ persist,
239
262
  extraKey:
240
263
  revision === undefined
241
264
  ? undefined
@@ -265,3 +288,24 @@ export function live<
265
288
  Layer.provide(State.live(revisionState, revisionZero)),
266
289
  )
267
290
  }
291
+
292
+ /**
293
+ * Mark a query's value stale (`isStale := true`) — without fetching. A stale
294
+ * query with active readers (a mounted view) refetches; an unread one refetches
295
+ * when next subscribed. The imperative counterpart of `invalidateOn`, and what a
296
+ * focus/online provider layer calls. A no-op if the calc isn't live; resolves the
297
+ * `Queries` registry (part of `Engine`).
298
+ */
299
+ export const invalidate = (calc: { readonly name: string }): Effect.Effect<void, never, Queries> =>
300
+ Effect.flatMap(Queries, (queries) =>
301
+ Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
302
+ )
303
+
304
+ /**
305
+ * Force an immediate refetch of the current key, bypassing the no-op-key guard
306
+ * and independent of `isStale`. A no-op if the calc isn't live or is disabled.
307
+ */
308
+ export const refetch = (calc: { readonly name: string }): Effect.Effect<void, never, Queries> =>
309
+ Effect.flatMap(Queries, (queries) =>
310
+ Effect.sync(() => queries.byName.get(calc.name)?.refetch()),
311
+ )
@@ -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>(s: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> =>
41
+ Option.match(s.error, {
42
+ onSome: (error) => AsyncData.error(error, s.isFetching),
43
+ onNone: () =>
44
+ Option.match(s.data, {
45
+ onSome: (value) => AsyncData.success(value, s.isFetching),
46
+ onNone: () => (s.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>(s: QueryState<A, E>): boolean =>
52
+ s.isFetching && Option.isNone(s.data)
@@ -4,21 +4,30 @@ import { type Manifest, definitionClass } from '../definition/definition'
4
4
  import { type Ctx } from '../internal/ctx'
5
5
  import type { Tracker } from '../internal/track'
6
6
  import { CurrentTracker } from '../internal/track'
7
- import { CurrentSlots, type SlotHost } from './host'
8
- import type { Node, SlotClass } from './slot'
7
+ import type { SlotClass } from './slot'
8
+ import type { Structure } from './structure'
9
9
  import type { UiClass, UiContract } from './ui'
10
10
  import { Props } from './props'
11
11
 
12
- /** Everything the host injects per render: the instance props, the dependency tracker, and slot bindings. */
12
+ /**
13
+ * What a composition's logic returns for one frame: a serializable {@link Structure}
14
+ * value — its computed props, per-slot fills, and event triggers, all as DATA. A body
15
+ * never returns (nor evaluates) a React node, so the engine, the wire server, and the
16
+ * proof harness consume the frame without React; presentation resolves the contract's
17
+ * view separately, by name, on a host.
18
+ */
19
+ export type Frame<C extends UiContract = UiContract> = Structure<C>
20
+
21
+ /** Everything the host injects per render: the instance props and the dependency tracker. */
13
22
  export interface RenderEnv {
14
23
  readonly props: unknown
15
24
  readonly tracker: Tracker
16
- readonly slots: SlotHost
17
25
  }
18
26
 
19
- /** A mounted composition: given a render environment, produces the current frame's node. */
27
+ /** A mounted composition: given a render environment, produces the current frame
28
+ * a {@link Structure} value (see `Frame`). */
20
29
  export interface CompositionService {
21
- readonly render: (env: RenderEnv) => Effect.Effect<Node, never, never>
30
+ readonly render: (env: RenderEnv) => Effect.Effect<Frame, never, never>
22
31
  }
23
32
 
24
33
  export interface CompositionConfig {
@@ -39,7 +48,20 @@ export interface CompositionConfig {
39
48
 
40
49
  type PropsOf<Config> = Config extends { props: Schema.Schema<infer P, any> } ? P : unknown
41
50
 
42
- export interface CompositionClass<out P, out C extends UiContract = UiContract> {
51
+ /**
52
+ * The composition's state-group tuple, recovered from the `const`-inferred config
53
+ * so consumers can type its seeds. Erased to `readonly []` when no `states` are
54
+ * declared; entries that are not state groups contribute no seed keys (`SeedsOf`).
55
+ */
56
+ type StatesOf<Config> = Config extends { states: infer S extends ReadonlyArray<unknown> }
57
+ ? S
58
+ : readonly []
59
+
60
+ export interface CompositionClass<
61
+ out P,
62
+ out C extends UiContract = UiContract,
63
+ out S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
64
+ > {
43
65
  new (): {}
44
66
  readonly manifest: Manifest & { readonly kind: 'Composition' } & CompositionConfig
45
67
  /** Phantom carrying the props type for `provide(slot, composition)` checks. */
@@ -51,6 +73,11 @@ export interface CompositionClass<out P, out C extends UiContract = UiContract>
51
73
  * invariant `impl` tag is never stored here, so no variance regression.
52
74
  */
53
75
  readonly Contract: C
76
+ /**
77
+ * Phantom carrying the state-group tuple, so `seedScene` can type a scene's
78
+ * seeds against the actual state members. Same erasure trick as `Contract`.
79
+ */
80
+ readonly States: S
54
81
  readonly tag: Context.Tag<CompositionService, CompositionService>
55
82
  }
56
83
 
@@ -66,12 +93,12 @@ export const make = <const Config extends CompositionConfig, C extends UiContrac
66
93
  // manifest still sees only the erased `{ manifest }` carrier (CompositionConfig),
67
94
  // so the manifest type — and its variance — is unchanged.
68
95
  config: Config & { readonly ui: UiClass<C> },
69
- ): CompositionClass<PropsOf<Config>, C> => {
96
+ ): CompositionClass<PropsOf<Config>, C, StatesOf<Config>> => {
70
97
  const tag = Context.GenericTag<CompositionService, CompositionService>(
71
98
  `reform/composition/${name}`,
72
99
  )
73
100
  const manifest = { kind: 'Composition' as const, name, ...config }
74
- return definitionClass<CompositionClass<PropsOf<Config>, C>>({ manifest, tag })
101
+ return definitionClass<CompositionClass<PropsOf<Config>, C, StatesOf<Config>>>({ manifest, tag })
75
102
  }
76
103
 
77
104
  /**
@@ -80,33 +107,40 @@ export const make = <const Config extends CompositionConfig, C extends UiContrac
80
107
  * host injects per render) surface as the Layer's `RIn`; the runtime runs the
81
108
  * body per render within the captured context.
82
109
  */
83
- export const live = <P, Eff extends YieldWrap<Effect.Effect<unknown, never, unknown>>>(
84
- // `live` doesn't use the contract type; accept any composition.
85
- composition: CompositionClass<P, any>,
86
- body: () => Generator<Eff, Node, never>,
110
+ export const live = <
111
+ P,
112
+ C extends UiContract,
113
+ Eff extends YieldWrap<Effect.Effect<unknown, never, unknown>>,
114
+ >(
115
+ // The contract `C` is captured (not erased to `any`) so the body's returned
116
+ // `Structure` is checked against the composition's contract.
117
+ composition: CompositionClass<P, C>,
118
+ body: () => Generator<Eff, Frame<C>, never>,
87
119
  ): Layer.Layer<CompositionService, never, Exclude<Ctx<Eff>, Props>> => {
88
120
  const logic = Effect.gen(body)
89
121
  return Layer.effect(
90
122
  composition.tag,
91
123
  Effect.gen(function* () {
92
- // Capture the build-time context (state stores, calc, ui, bus). `Props`,
93
- // the tracker, and slots are injected per render below.
124
+ // Capture the build-time context (state stores, calc, ui, bus). `Props` and
125
+ // the tracker are injected per render below.
94
126
  const context = yield* Effect.context<Exclude<Ctx<Eff>, Props>>()
95
- const render = (env: RenderEnv): Effect.Effect<Node, never, never> =>
96
- // Total once fully provided: the body is synchronous, reads can't fail,
97
- // and every requirement is now satisfied (D2). `Effect.gen` widens the
98
- // error channel to `unknown` for a generic body, so we restate it.
127
+ const render = (env: RenderEnv): Effect.Effect<Frame, never, never> =>
128
+ // The per-render erasure seam: the body is total once fully provided (it is
129
+ // synchronous, reads can't fail, every requirement is now satisfied D2) and
130
+ // its `Structure<C>` erases to the service's contract-agnostic `Frame`. `gen`
131
+ // widens the error channel to the body's inferred `E` for a generic body, so
132
+ // narrowing it (and the contract) back to the erased service type needs the
133
+ // `unknown` bridge — the one cast this render boundary has always required.
99
134
  logic.pipe(
100
135
  Effect.provideService(Props, env.props),
101
136
  Effect.provideService(CurrentTracker, env.tracker),
102
- Effect.provideService(CurrentSlots, env.slots),
103
137
  Effect.provide(context),
104
- ) as Effect.Effect<Node, never, never>
138
+ ) as unknown as Effect.Effect<Frame, never, never>
105
139
  return { render }
106
140
  }),
107
141
  )
108
142
  }
109
143
 
110
- /** Run a mounted composition for one frame, producing its node. */
111
- export const render = (service: CompositionService, env: RenderEnv): Effect.Effect<Node, never, never> =>
144
+ /** Run a mounted composition for one frame, producing its `Frame` (a `Structure`). */
145
+ export const render = (service: CompositionService, env: RenderEnv): Effect.Effect<Frame, never, never> =>
112
146
  service.render(env)
@@ -1,18 +1,12 @@
1
- import { Context } from 'effect'
2
1
  import type { Node } from './slot'
3
2
 
4
3
  /**
5
- * Binds a parent's slots to concrete child renderers. The host package
6
- * (`@reform/react`) builds one per composition instance from the manifest's
7
- * slot map; the `ui` read consults it so `<slots.Item .../>` renders the wired
8
- * child. Core stays renderer-neutral it only defines this hole.
4
+ * A slot handle the host hands a presentation: BOTH renderable (call it / use as a
5
+ * component to place the keyed children) AND inspectable (`.props` — the array of
6
+ * per-child fill props the structure carried, one entry per mounted child). The
7
+ * structure host (`renderStructure`) populates `.props` from the fills. Plan 06.
9
8
  */
10
- export interface SlotHost {
11
- slot(name: string): (props: unknown) => Node
9
+ export interface SlotRenderer {
10
+ (props: unknown): Node
11
+ readonly props: ReadonlyArray<unknown>
12
12
  }
13
-
14
- const CurrentSlotsBase: Context.TagClass<CurrentSlots, 'reform/Slots', SlotHost> =
15
- Context.Tag('reform/Slots')<CurrentSlots, SlotHost>()
16
-
17
- /** The active composition's slot bindings, provided per render by the host. */
18
- export class CurrentSlots extends CurrentSlotsBase {}
@@ -0,0 +1,90 @@
1
+ import { Schema as S } from 'effect'
2
+ import { describe, expect, test } from 'vitest'
3
+ import { Composition } from '../index'
4
+ import { slot } from './slot'
5
+ import { type Contract, ui } from './ui'
6
+ import { absent, each, isStructure, mount, one, structureEquals, when } from './structure'
7
+
8
+ type List = Contract<typeof ListUi>
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
+ class RowUi extends ui('Row')<{ props: { label: string } }>() {}
15
+ class Row extends Composition.make('Row', {
16
+ title: 'Row',
17
+ props: S.Struct({ label: S.String }),
18
+ ui: RowUi,
19
+ }) {}
20
+ class RowSlot extends slot('Row')<typeof Row>() {}
21
+
22
+ class ListUi extends ui('List')<{
23
+ props: { total: number }
24
+ slots: { Row: RowSlot }
25
+ }>() {}
26
+
27
+ describe('structure combinators', () => {
28
+ test('each produces one keyed entry per item, props mapped', () => {
29
+ const fill = each([{ id: 'a' }, { id: 'b' }], { key: (t) => t.id, props: (t) => ({ label: t.id }) })
30
+ expect(fill).toEqual({
31
+ _tag: 'Each',
32
+ items: [
33
+ { key: 'a', props: { label: 'a' } },
34
+ { key: 'b', props: { label: 'b' } },
35
+ ],
36
+ })
37
+ })
38
+
39
+ test('one and absent / when', () => {
40
+ expect(one({ label: 'x' })).toEqual({ _tag: 'One', props: { label: 'x' } })
41
+ expect(absent).toEqual({ _tag: 'Absent' })
42
+ expect(when(true, one({ label: 'y' }))).toEqual({ _tag: 'One', props: { label: 'y' } })
43
+ expect(when(false, one({ label: 'y' }))).toEqual({ _tag: 'Absent' })
44
+ })
45
+
46
+ test('mount stamps the Structure brand; isStructure discriminates it from a node', () => {
47
+ const s = mount<List>({
48
+ props: { total: 2 },
49
+ slots: { Row: each([{ id: 'a' }], { key: (t) => t.id, props: (t) => ({ label: t.id }) }) },
50
+ })
51
+ expect(isStructure(s)).toBe(true)
52
+ expect(isStructure({ type: 'div' })).toBe(false)
53
+ expect(isStructure(null)).toBe(false)
54
+ expect(s.props).toEqual({ total: 2 })
55
+ })
56
+
57
+ test('structureEquals detects a two-frame fixpoint vs a change', () => {
58
+ const make = (label: string) =>
59
+ mount<List>({
60
+ props: { total: 1 },
61
+ slots: { Row: each([{ id: 'a' }], { key: (t) => t.id, props: () => ({ label }) }) },
62
+ })
63
+ expect(structureEquals(make('same'), make('same'))).toBe(true)
64
+ expect(structureEquals(make('a'), make('b'))).toBe(false)
65
+ })
66
+ })
67
+
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
+ export const _typeChecks = (): void => {
71
+ mount<List>({
72
+ props: { total: 1 },
73
+ slots: {
74
+ // @ts-expect-error 'Nope' is not a declared slot of ListUi
75
+ Nope: one({ label: 'x' }),
76
+ },
77
+ })
78
+ mount<List>({
79
+ props: { total: 1 },
80
+ slots: {
81
+ // @ts-expect-error Row's child props are { label: string }, not { wrong: number }
82
+ Row: each([1], { key: () => 'k', props: () => ({ wrong: 1 }) }),
83
+ },
84
+ })
85
+ mount<List>({
86
+ // @ts-expect-error List's props are { total: number }, not { total: string }
87
+ props: { total: 'two' },
88
+ slots: { Row: absent },
89
+ })
90
+ }