@playfast/reform 1.3.0 → 1.4.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.
Files changed (188) hide show
  1. package/README.md +15 -6
  2. package/dist/calc/asyncCalc.d.ts +3 -1
  3. package/dist/calc/asyncCalc.d.ts.map +1 -1
  4. package/dist/calc/asyncCalc.js +15 -5
  5. package/dist/calc/asyncCalc.js.map +1 -1
  6. package/dist/calc/asyncCalcTypes.d.ts +2 -0
  7. package/dist/calc/asyncCalcTypes.d.ts.map +1 -1
  8. package/dist/calc/calcFamily.d.ts.map +1 -1
  9. package/dist/calc/calcFamily.js +77 -34
  10. package/dist/calc/calcFamily.js.map +1 -1
  11. package/dist/calc/queryState.d.ts +3 -1
  12. package/dist/calc/queryState.d.ts.map +1 -1
  13. package/dist/calc/queryState.js +31 -9
  14. package/dist/calc/queryState.js.map +1 -1
  15. package/dist/channel/channel.d.ts.map +1 -1
  16. package/dist/channel/channel.js +6 -1
  17. package/dist/channel/channel.js.map +1 -1
  18. package/dist/compose/composition.d.ts.map +1 -1
  19. package/dist/compose/composition.js +7 -1
  20. package/dist/compose/composition.js.map +1 -1
  21. package/dist/compose/structure.d.ts.map +1 -1
  22. package/dist/compose/structure.js +33 -8
  23. package/dist/compose/structure.js.map +1 -1
  24. package/dist/compose/ui.d.ts.map +1 -1
  25. package/dist/compose/ui.js +16 -5
  26. package/dist/compose/ui.js.map +1 -1
  27. package/dist/event/event.d.ts +1 -1
  28. package/dist/event/event.d.ts.map +1 -1
  29. package/dist/event/event.js +26 -4
  30. package/dist/event/event.js.map +1 -1
  31. package/dist/internal/familyKeys.d.ts +16 -0
  32. package/dist/internal/familyKeys.d.ts.map +1 -0
  33. package/dist/internal/familyKeys.js +35 -0
  34. package/dist/internal/familyKeys.js.map +1 -0
  35. package/dist/internal/platform.d.ts +43 -0
  36. package/dist/internal/platform.d.ts.map +1 -0
  37. package/dist/internal/platform.js +68 -0
  38. package/dist/internal/platform.js.map +1 -0
  39. package/dist/internal/queryDriver.d.ts.map +1 -1
  40. package/dist/internal/queryDriver.js +88 -64
  41. package/dist/internal/queryDriver.js.map +1 -1
  42. package/dist/internal/queryDriverConsume.d.ts +9 -0
  43. package/dist/internal/queryDriverConsume.d.ts.map +1 -0
  44. package/dist/internal/queryDriverConsume.js +16 -0
  45. package/dist/internal/queryDriverConsume.js.map +1 -0
  46. package/dist/internal/queryDriverHydrate.d.ts.map +1 -1
  47. package/dist/internal/queryDriverHydrate.js +9 -3
  48. package/dist/internal/queryDriverHydrate.js.map +1 -1
  49. package/dist/internal/queryDriverPersist.d.ts +16 -0
  50. package/dist/internal/queryDriverPersist.d.ts.map +1 -0
  51. package/dist/internal/queryDriverPersist.js +15 -0
  52. package/dist/internal/queryDriverPersist.js.map +1 -0
  53. package/dist/internal/queryDriverPersistWarn.d.ts +12 -0
  54. package/dist/internal/queryDriverPersistWarn.d.ts.map +1 -0
  55. package/dist/internal/queryDriverPersistWarn.js +31 -0
  56. package/dist/internal/queryDriverPersistWarn.js.map +1 -0
  57. package/dist/internal/queryDriverSettle.d.ts +23 -0
  58. package/dist/internal/queryDriverSettle.d.ts.map +1 -0
  59. package/dist/internal/queryDriverSettle.js +52 -0
  60. package/dist/internal/queryDriverSettle.js.map +1 -0
  61. package/dist/internal/queryDriverStore.d.ts +2 -28
  62. package/dist/internal/queryDriverStore.d.ts.map +1 -1
  63. package/dist/internal/queryDriverStore.js +5 -29
  64. package/dist/internal/queryDriverStore.js.map +1 -1
  65. package/dist/internal/queryDriverTypes.d.ts +3 -0
  66. package/dist/internal/queryDriverTypes.d.ts.map +1 -1
  67. package/dist/internal/scheduler.d.ts +1 -0
  68. package/dist/internal/scheduler.d.ts.map +1 -1
  69. package/dist/internal/scheduler.js +69 -6
  70. package/dist/internal/scheduler.js.map +1 -1
  71. package/dist/internal.d.ts +2 -0
  72. package/dist/internal.d.ts.map +1 -1
  73. package/dist/internal.js +4 -0
  74. package/dist/internal.js.map +1 -1
  75. package/dist/reducer/reducer.js +3 -3
  76. package/dist/reducer/reducer.js.map +1 -1
  77. package/dist/remote/pendingQueue.d.ts +4 -2
  78. package/dist/remote/pendingQueue.d.ts.map +1 -1
  79. package/dist/remote/pendingQueue.js +20 -4
  80. package/dist/remote/pendingQueue.js.map +1 -1
  81. package/dist/remote/remoteState.d.ts.map +1 -1
  82. package/dist/remote/remoteState.js +27 -14
  83. package/dist/remote/remoteState.js.map +1 -1
  84. package/dist/remote/remoteStateSend.d.ts +3 -1
  85. package/dist/remote/remoteStateSend.d.ts.map +1 -1
  86. package/dist/remote/remoteStateSend.js +19 -3
  87. package/dist/remote/remoteStateSend.js.map +1 -1
  88. package/dist/remote/remoteStateTypes.d.ts +3 -0
  89. package/dist/remote/remoteStateTypes.d.ts.map +1 -1
  90. package/dist/runtime/bus.d.ts +5 -0
  91. package/dist/runtime/bus.d.ts.map +1 -1
  92. package/dist/runtime/bus.js +1 -1
  93. package/dist/runtime/bus.js.map +1 -1
  94. package/dist/runtime/capturedAppRuntime.d.ts.map +1 -1
  95. package/dist/runtime/capturedAppRuntime.js +37 -51
  96. package/dist/runtime/capturedAppRuntime.js.map +1 -1
  97. package/dist/runtime/eventBudget.d.ts.map +1 -1
  98. package/dist/runtime/eventBudget.js +2 -1
  99. package/dist/runtime/eventBudget.js.map +1 -1
  100. package/dist/runtime/featureMount.d.ts +34 -2
  101. package/dist/runtime/featureMount.d.ts.map +1 -1
  102. package/dist/runtime/featureMount.js +66 -2
  103. package/dist/runtime/featureMount.js.map +1 -1
  104. package/dist/runtime/loop.d.ts +12 -0
  105. package/dist/runtime/loop.d.ts.map +1 -1
  106. package/dist/runtime/loop.js +8 -5
  107. package/dist/runtime/loop.js.map +1 -1
  108. package/dist/state/state.d.ts +2 -2
  109. package/dist/state/state.d.ts.map +1 -1
  110. package/dist/state/state.js +2 -2
  111. package/dist/state/state.js.map +1 -1
  112. package/dist/state/stateFamily.d.ts +2 -2
  113. package/dist/state/stateFamily.d.ts.map +1 -1
  114. package/dist/state/stateFamily.js +80 -32
  115. package/dist/state/stateFamily.js.map +1 -1
  116. package/dist/state/stateGroup.d.ts +5 -5
  117. package/dist/state/stateGroup.d.ts.map +1 -1
  118. package/dist/state/stateGroup.js +1 -1
  119. package/dist/state/stateGroup.js.map +1 -1
  120. package/dist/testkit/flight.testkit.d.ts.map +1 -1
  121. package/dist/testkit/flight.testkit.js +2 -1
  122. package/dist/testkit/flight.testkit.js.map +1 -1
  123. package/dist/ui/trigger.d.ts +3 -0
  124. package/dist/ui/trigger.d.ts.map +1 -1
  125. package/dist/ui/trigger.js +14 -1
  126. package/dist/ui/trigger.js.map +1 -1
  127. package/dist/wire/tree.d.ts.map +1 -1
  128. package/dist/wire/tree.js +6 -1
  129. package/dist/wire/tree.js.map +1 -1
  130. package/package.json +1 -1
  131. package/src/calc/asyncCalc-key-attribution.test.ts +182 -0
  132. package/src/calc/asyncCalc-registry-name.test.ts +82 -0
  133. package/src/calc/asyncCalc.superseded-commit.test.ts +162 -0
  134. package/src/calc/asyncCalc.test.ts +35 -7
  135. package/src/calc/asyncCalc.ts +33 -10
  136. package/src/calc/asyncCalcTypes.ts +6 -0
  137. package/src/calc/calcFamily.ts +81 -34
  138. package/src/calc/family-identity.test.ts +231 -0
  139. package/src/calc/queryState.ts +36 -5
  140. package/src/channel/channel-throttle-frames.test.ts +163 -0
  141. package/src/channel/channel.ts +5 -0
  142. package/src/compose/compose-identity.test.ts +175 -0
  143. package/src/compose/composition.ts +8 -4
  144. package/src/compose/structure.ts +45 -13
  145. package/src/compose/ui.ts +22 -5
  146. package/src/event/event.ts +34 -5
  147. package/src/event/syncTrigger.test.ts +66 -0
  148. package/src/internal/familyKeys.ts +41 -0
  149. package/src/internal/platform.test.ts +55 -0
  150. package/src/internal/platform.ts +72 -0
  151. package/src/internal/queryDriver-defect-attribution.test.ts +107 -0
  152. package/src/internal/queryDriver-persist-key.test.ts +118 -0
  153. package/src/internal/queryDriver.persistence-staleness.test.ts +209 -0
  154. package/src/internal/queryDriver.ts +92 -80
  155. package/src/internal/queryDriverConsume.ts +42 -0
  156. package/src/internal/queryDriverHydrate.ts +9 -3
  157. package/src/internal/queryDriverPersist.ts +42 -0
  158. package/src/internal/queryDriverPersistWarn.ts +61 -0
  159. package/src/internal/queryDriverSettle.ts +85 -0
  160. package/src/internal/queryDriverStore.ts +9 -74
  161. package/src/internal/queryDriverTypes.ts +10 -0
  162. package/src/internal/queryState-and-scheduler-audit.test.ts +170 -0
  163. package/src/internal/scheduler-multi-source-wake.test.ts +111 -0
  164. package/src/internal/scheduler.ts +80 -6
  165. package/src/internal.ts +4 -0
  166. package/src/reducer/reducer.ts +3 -3
  167. package/src/remote/pendingQueue.ts +35 -5
  168. package/src/remote/remoteState.intent-key-stamp.test.ts +207 -0
  169. package/src/remote/remoteState.key-attribution.test.ts +174 -0
  170. package/src/remote/remoteState.ts +27 -15
  171. package/src/remote/remoteStateSend.ts +29 -8
  172. package/src/remote/remoteStateTypes.ts +7 -0
  173. package/src/runtime/bus.ts +6 -1
  174. package/src/runtime/capturedAppRuntime.ts +134 -110
  175. package/src/runtime/eventBudget.ts +2 -1
  176. package/src/runtime/feature-mount-ownership.test.ts +539 -0
  177. package/src/runtime/featureMount.ts +110 -3
  178. package/src/runtime/loop.ts +29 -6
  179. package/src/runtime/mount-inflight-ownership.test.ts +361 -0
  180. package/src/runtime/mount-scope-hardening.test.ts +440 -0
  181. package/src/runtime/runtime.schema-identity.test.ts +188 -0
  182. package/src/state/state.ts +8 -4
  183. package/src/state/stateFamily.ts +86 -36
  184. package/src/state/stateGroup.ts +24 -6
  185. package/src/testkit/flight.testkit.ts +2 -1
  186. package/src/ui/trigger.ts +18 -0
  187. package/src/wire/tree-key-presence.test.ts +46 -0
  188. package/src/wire/tree.ts +9 -1
@@ -0,0 +1,163 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Clock, Duration, Effect, Layer, Option, Schema as S, TestClock } from 'effect'
3
+ import { Channel, Engine, Event, Procedure } from '../index'
4
+ import { Channels } from './channel'
5
+
6
+ // The drain loop notifies on the macrotask/microtask queue; draining turns is
7
+ // exact where a duration would be a guess.
8
+ const settle = Effect.yieldNow().pipe(Effect.repeatN(30))
9
+
10
+ // `runtime/loop.ts` hands a whole frame to a channel as N synchronous `offer`
11
+ // calls:
12
+ //
13
+ // ordered.forEach((envelope) => {
14
+ // procedures.channelsFor(envelope.event._tag).forEach((channelName) => {
15
+ // channels.get(channelName)?.offer(envelope.event)
16
+ // })
17
+ // })
18
+ //
19
+ // Reaching the registration is that exact code path — it only removes the
20
+ // scheduling luck of getting N publishes into one `Queue.takeAll`. Everything
21
+ // downstream (the queue, the policy stream, `runMatching`) is untouched.
22
+ const offerFrame = (
23
+ name: string,
24
+ events: ReadonlyArray<{ readonly _tag: string }>,
25
+ ): Effect.Effect<void, never, Channels> =>
26
+ Effect.gen(function* () {
27
+ const channels = yield* Channels
28
+ yield* Option.match(Option.fromNullable(channels.byName.get(name)), {
29
+ onNone: () => Effect.dieMessage(`reform: no live channel '${name}'`),
30
+ onSome: (registration) =>
31
+ Effect.sync(() => {
32
+ events.forEach((event) => registration.offer(event))
33
+ }),
34
+ })
35
+ })
36
+
37
+ const throttlePolicy = (
38
+ units: number,
39
+ strategy: Option.Option<'enforce' | 'shape'>,
40
+ ): Channel.ChannelPolicy => ({
41
+ _tag: 'throttle',
42
+ units,
43
+ duration: Duration.seconds(1),
44
+ cost: Option.none(),
45
+ burst: Option.none(),
46
+ strategy,
47
+ })
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // BUG 1 — a throttled lane charges the whole frame as one indivisible chunk,
51
+ // so an `enforce` lane drops events it had the budget to admit.
52
+ //
53
+ // `channel.ts` throttles the raw event stream, which `Stream.fromQueue` emits
54
+ // one chunk per frame. `cost: (chunk) => Chunk.size(chunk) * cost` prices the
55
+ // frame, and `enforce` drops any chunk it cannot pay for *in full*. A lane
56
+ // rated `2 per second` therefore admits 2 of 3 events when they arrive in
57
+ // three frames, and 0 of 3 when the same three arrive in one frame.
58
+ // ---------------------------------------------------------------------------
59
+ it.effect('a throttle lane admits the events a frame has budget for', () => {
60
+ class Tick extends Event.make('HuntEnforceTick', S.Struct({ n: S.Number })) {}
61
+ class Spread extends Channel.make('HuntEnforceSpread', {
62
+ policy: throttlePolicy(2, Option.some('enforce' as const)),
63
+ }) {}
64
+ class Burst extends Channel.make('HuntEnforceBurst', {
65
+ policy: throttlePolicy(2, Option.some('enforce' as const)),
66
+ }) {}
67
+ class SpreadWork extends Procedure.make('HuntEnforceSpreadWork', {
68
+ events: [Tick],
69
+ channel: Spread,
70
+ }) {}
71
+ class BurstWork extends Procedure.make('HuntEnforceBurstWork', {
72
+ events: [Tick],
73
+ channel: Burst,
74
+ }) {}
75
+
76
+ const spread: Array<number> = []
77
+ const burst: Array<number> = []
78
+ const layer = Layer.mergeAll(
79
+ Channel.live(Spread),
80
+ Channel.live(Burst),
81
+ Procedure.live(SpreadWork, function* (event) {
82
+ spread.push(event.n)
83
+ }),
84
+ Procedure.live(BurstWork, function* (event) {
85
+ burst.push(event.n)
86
+ }),
87
+ ).pipe(Layer.provideMerge(Engine))
88
+
89
+ const ticks = [
90
+ Event.construct(Tick, { n: 1 }),
91
+ Event.construct(Tick, { n: 2 }),
92
+ Event.construct(Tick, { n: 3 }),
93
+ ]
94
+
95
+ return Effect.gen(function* () {
96
+ // Control: the same three events, one per frame, on the same policy.
97
+ yield* Effect.forEach(ticks, (tick) =>
98
+ offerFrame('HuntEnforceSpread', [tick]).pipe(Effect.zipRight(settle)),
99
+ )
100
+ // The bucket holds 2 units and the clock never moves, so 1 and 2 are
101
+ // admitted and 3 is dropped. This is what `enforce` is supposed to do.
102
+ expect(spread).toEqual([1, 2])
103
+
104
+ // Same policy, same three events, one frame.
105
+ yield* offerFrame('HuntEnforceBurst', ticks)
106
+ yield* settle
107
+ yield* TestClock.adjust(Duration.seconds(10))
108
+ yield* settle
109
+
110
+ // The lane has the same 2 units of budget, so 1 and 2 must run. Today the
111
+ // queue hands the parked stream `[1]` and then `[2, 3]`: the second chunk
112
+ // is priced at 2 against the 1 unit left, cannot be paid for in full, and
113
+ // is discarded whole — event 2 is dropped even though the lane had exactly
114
+ // the budget for it, and it never comes back.
115
+ expect(burst).toEqual([1, 2])
116
+ }).pipe(Effect.provide(layer))
117
+ })
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // BUG 2 — the same whole-frame pricing makes the default `shape` strategy
121
+ // stall the head of a frame and then release the frame simultaneously.
122
+ //
123
+ // On a lane rated `1 per second`, three events in one frame produce: no run at
124
+ // t=0 (the frame waits until it can pay for all three) and then three runs
125
+ // starting together at t=2000. Both halves are wrong: the bucket starts full so
126
+ // the first event is owed an immediate run, and three concurrent starts inside
127
+ // one second is the opposite of the rate the policy declares.
128
+ // ---------------------------------------------------------------------------
129
+ it.effect('a throttle lane paces a frame instead of releasing it at once', () => {
130
+ class Tick extends Event.make('HuntShapeTick', S.Struct({ n: S.Number })) {}
131
+ class Lane extends Channel.make('HuntShapeLane', {
132
+ policy: throttlePolicy(1, Option.none()),
133
+ }) {}
134
+ class Work extends Procedure.make('HuntShapeWork', { events: [Tick], channel: Lane }) {}
135
+
136
+ const startedAt: Array<number> = []
137
+ const layer = Layer.mergeAll(
138
+ Channel.live(Lane),
139
+ Procedure.live(Work, function* () {
140
+ const now = yield* Clock.currentTimeMillis
141
+ startedAt.push(now)
142
+ }),
143
+ ).pipe(Layer.provideMerge(Engine))
144
+
145
+ return Effect.gen(function* () {
146
+ yield* offerFrame('HuntShapeLane', [
147
+ Event.construct(Tick, { n: 1 }),
148
+ Event.construct(Tick, { n: 2 }),
149
+ Event.construct(Tick, { n: 3 }),
150
+ ])
151
+ yield* settle
152
+
153
+ // The token bucket starts full, so the head of the frame owes no wait.
154
+ expect(startedAt).toEqual([0])
155
+
156
+ yield* TestClock.adjust(Duration.seconds(10))
157
+ yield* settle
158
+
159
+ expect(startedAt).toHaveLength(3)
160
+ // A lane rated `1 per second` must never start two runs in the same instant.
161
+ expect(new Set(startedAt).size).toBe(3)
162
+ }).pipe(Effect.provide(layer))
163
+ })
@@ -186,6 +186,11 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
186
186
  ),
187
187
  Match.tag('throttle', (policy) =>
188
188
  events.pipe(
189
+ // One event per chunk. The drain hands a whole frame over as N offers,
190
+ // which arrives as one chunk; `Stream.throttle` prices a chunk whole and
191
+ // drops (or delays) any it cannot pay for in full, so a frame of three on
192
+ // a lane rated `2 per second` loses all three rather than admitting two.
193
+ Stream.rechunk(1),
189
194
  Stream.throttle({
190
195
  cost: (chunk) => Chunk.size(chunk) * Option.getOrElse(policy.cost, () => 1),
191
196
  units: policy.units,
@@ -0,0 +1,175 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Data, Effect, Layer, Option, Schema as S } from 'effect'
3
+ import {
4
+ absent,
5
+ AsyncCalc,
6
+ Boundary,
7
+ Composition,
8
+ each,
9
+ mount,
10
+ provide,
11
+ slot,
12
+ State,
13
+ StateGroup,
14
+ structureEquals,
15
+ Ui,
16
+ ui,
17
+ } from '../index'
18
+ import type { Contract } from './ui'
19
+ import { makeFlightGate, until } from '../testkit/flight.testkit'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // 1. compose/composition.ts:78 — `parseProps` is built with `decodeUnknownOption`,
23
+ // so it parses the schema's ENCODED side. But `PropsOf<Config>` (and therefore
24
+ // `CompositionClass['Props']`, `RenderEnv.props`, `SlotProps`, and every
25
+ // `one(...)`/`each(...)` fill) is the schema's TYPE side, and every call site
26
+ // (react/compose.ts:198, proof/engineSink.ts:137, reform-remote/wireRender.ts:63,
27
+ // feature/featureFactory.ts:62) hands it an already-decoded Type value. This is
28
+ // the same defect the package already fixed for `State` (state.ts:105, "check the
29
+ // Type side — decodeUnknownSync would reject transforming schemas") and `Event`
30
+ // (event.ts:53); the props path was missed. A composition whose props schema is
31
+ // not an identity transform can never render.
32
+
33
+ const propsSchema = S.Struct({ count: S.NumberFromString })
34
+
35
+ class StampUi extends ui('hunt.StampUi')<{ props: { readonly count: number } }>() {}
36
+ class Stamp extends Composition.make('hunt.Stamp', {
37
+ title: 'Stamp',
38
+ props: propsSchema,
39
+ ui: StampUi,
40
+ })<Stamp>() {}
41
+
42
+ it('composition: parseProps accepts the props value its own Props type declares', () => {
43
+ // The declared Props type of this composition is `{ count: number }` — this
44
+ // assignment is what the framework's own typing promises a parent may render with.
45
+ const props: typeof Stamp.Props = { count: 3 }
46
+ // `Schema.validateOption` — what State/Event/`makeFeatureInputParser` use for an
47
+ // already-decoded domain value — accepts it:
48
+ expect(S.validateOption(propsSchema)(props)).toEqual(Option.some({ count: 3 }))
49
+ // `parseProps` accepts only the ENCODED form, which no call site ever holds:
50
+ expect(Stamp.parseProps({ count: '3' })).toEqual(Option.some({ count: 3 }))
51
+ expect(Stamp.parseProps(props)).toEqual(Option.some({ count: 3 }))
52
+ })
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // 2. compose/ui.ts:150-151 — a Ui contract's runtime tag key is `reform/ui/${name}`
56
+ // with no per-definition discriminator, unlike `slot()` (slot.ts:150) and
57
+ // `Composition.make` (composition.ts:57) which both mint a sequenced key so that
58
+ // independently authored same-name definitions stay distinct. Two features that
59
+ // each declare `ui('Header')` silently share one provider entry: the last
60
+ // `provide` wins and the wrong view renders for the other contract.
61
+
62
+ it.live('ui: independently authored same-name contracts resolve their own views', () => {
63
+ class First extends ui('hunt.Header')<{ props: { readonly label: string } }>() {}
64
+ class Second extends ui('hunt.Header')<{ props: { readonly label: string } }>() {}
65
+ const FirstView = Ui.make(First, ({ label }) => `first:${label}`)
66
+ const SecondView = Ui.make(Second, ({ label }) => `second:${label}`)
67
+ const Live = Layer.mergeAll(provide(First, FirstView), provide(Second, SecondView))
68
+ return Effect.gen(function* () {
69
+ const first = yield* First.impl
70
+ const second = yield* Second.impl
71
+ expect(second({ label: 'x' }, {}, {})).toBe('second:x')
72
+ expect(first({ label: 'x' }, {}, {})).toBe('first:x')
73
+ }).pipe(Effect.provide(Live))
74
+ })
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // 3. calc/queryState.ts:37 read through boundary/boundary.ts:194 — when the inputs
78
+ // move, a withheld previous-key result reads Loading, so the Boundary re-pends.
79
+ // `keepPreviousData: true` is documented as retaining the previous key's PAYLOAD
80
+ // ("safe when the payload is only displayed"), but the same switch also republishes
81
+ // the previous key's ERROR as the new key's `Error` arm. Here nothing is being
82
+ // "kept" — the failed key produced no data at all — yet the Boundary reports
83
+ // Errored for a key that is still in flight and has never failed.
84
+
85
+ class Boom extends Data.TaggedError('hunt.Boom')<{ readonly detail: string }> {}
86
+
87
+ const keyGate = makeFlightGate()
88
+ Effect.runSync(keyGate.hold)
89
+
90
+ it.live('boundary: a previous key’s error must not gate the key now in flight', () => {
91
+ class Subject extends State.make('subject', S.Number) {}
92
+ class Inputs extends StateGroup.make(Subject) {}
93
+ class Q extends AsyncCalc.make('hunt.Q', {
94
+ inputs: [StateGroup.select(Inputs, 'subject')],
95
+ output: S.Number,
96
+ error: S.instanceOf(Boom),
97
+ alwaysOn: true,
98
+ }) {}
99
+ class Gate extends Boundary.make('hunt.Gate', { over: [Q] }) {}
100
+ const TestLayer = Boundary.live(Gate).pipe(
101
+ Layer.provideMerge(
102
+ AsyncCalc.live(Q, {
103
+ keepPreviousData: true,
104
+ query: ({ subject }) =>
105
+ subject === 1
106
+ ? Effect.fail(new Boom({ detail: 'subject-1 failed' }))
107
+ : keyGate.through(Effect.succeed(subject)),
108
+ }),
109
+ ),
110
+ Layer.provideMerge(StateGroup.live(Inputs, { subject: 1 })),
111
+ )
112
+ return Effect.gen(function* () {
113
+ const subject = yield* StateGroup.select(Inputs, 'subject').store
114
+ const qStore = yield* Q.store
115
+ const store = yield* Gate.store
116
+
117
+ yield* until(
118
+ () => store.get()._tag,
119
+ (t) => t === 'Errored',
120
+ )
121
+
122
+ subject.set(2)
123
+ // The gate proves the query for subject 2 is parked in flight and has never settled.
124
+ yield* keyGate.awaitEntry(1)
125
+ expect(store.get()._tag).toBe('Pending')
126
+ // Root cause: the arm itself reports key 1's failure as key 2's state.
127
+ expect(qStore.get()._tag).not.toBe('Error')
128
+
129
+ yield* keyGate.release
130
+ yield* until(
131
+ () => store.get()._tag,
132
+ (t) => t === 'Ready',
133
+ )
134
+ }).pipe(Effect.provide(TestLayer))
135
+ })
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // 4. compose/structure.ts:170-186 — `deepEquals` classifies every non-array object as
139
+ // a bag of own enumerable keys, so any value that carries its data outside such keys
140
+ // (Date, Map, Set, class instances) compares EQUAL to every other value of its kind.
141
+ // That verdict feeds `retainEach` (structure.ts:114), which then hands back the
142
+ // PREVIOUS render's fill: `each` returns props holding a stale value, not merely a
143
+ // stale equality answer.
144
+
145
+ class StampedUi extends ui('hunt.StampedUi')<{ props: { readonly at: Date } }>() {}
146
+ class Stamped extends Composition.make('hunt.Stamped', {
147
+ title: 'Stamped',
148
+ ui: StampedUi,
149
+ })<Stamped>() {}
150
+ class RowSlot extends slot('Row')<RowSlot, typeof Stamped>() {}
151
+ class BoardUi extends ui('hunt.BoardUi')<{
152
+ props: { readonly at: Date }
153
+ slots: { Row: RowSlot }
154
+ }>() {}
155
+ type Board = Contract<typeof BoardUi>
156
+
157
+ it('each: a changed prop value must not be retained from the previous fill', () => {
158
+ const rows = [{ id: 'a' }]
159
+ const first = each(rows, {
160
+ key: (row) => row.id,
161
+ props: (row) => ({ at: new Date(1), id: row.id }),
162
+ })
163
+ const second = each(rows, {
164
+ key: (row) => row.id,
165
+ props: (row) => ({ at: new Date(2), id: row.id }),
166
+ })
167
+ expect(first.items[0]?.props.at.getTime()).toBe(1)
168
+ expect(second.items[0]?.props.at.getTime()).toBe(2)
169
+ })
170
+
171
+ it('structureEquals: frames whose props differ must not compare equal', () => {
172
+ const left = mount<Board>({ props: { at: new Date(1) }, slots: { Row: absent } })
173
+ const right = mount<Board>({ props: { at: new Date(2) }, slots: { Row: absent } })
174
+ expect(structureEquals(left, right)).toBe(false)
175
+ })
@@ -50,7 +50,6 @@ export type {
50
50
  SlotClassesOf,
51
51
  } from './compositionTypes'
52
52
 
53
-
54
53
  const compositionDefinitionSequence: MutableRef.MutableRef<number> = GlobalValue.globalValue(
55
54
  Symbol.for('reform/CompositionDefinitionSequence'),
56
55
  () => MutableRef.make(0),
@@ -75,8 +74,14 @@ export const make =
75
74
  const propsSchema = Schema.make<PropsOf<Config>, EncodedPropsOf<Config>>(
76
75
  config.props?.ast ?? Schema.Unknown.ast,
77
76
  )
78
- const parseProps: (input: unknown) => Option.Option<PropsOf<Config>> =
79
- Schema.decodeUnknownOption(propsSchema)
77
+ // Both sides. `PropsOf<Config>` — and so every value a parent renders with — is
78
+ // the schema's Type, which is what the in-process call sites hold; a decode-only
79
+ // parser rejects a transforming props schema's own declared props. Props
80
+ // arriving over the wire are Encoded, so that reading has to keep working too.
81
+ const validateProps = Schema.validateOption(propsSchema)
82
+ const decodeProps = Schema.decodeUnknownOption(propsSchema)
83
+ const parseProps = (input: unknown): Option.Option<PropsOf<Config>> =>
84
+ Option.orElse(validateProps(input), () => decodeProps(input))
80
85
  const tag = Context.GenericTag<
81
86
  CompositionId<N, Self, RequiredSlotIdentities<C>>,
82
87
  CompositionService<PropsOf<Config>, C>
@@ -100,7 +105,6 @@ export const make =
100
105
  return composition
101
106
  }
102
107
 
103
-
104
108
  export const live = <
105
109
  P,
106
110
  C extends UiContract,
@@ -1,4 +1,4 @@
1
- import { Option, Struct } from 'effect'
1
+ import { Equal, Option, Struct } from 'effect'
2
2
  import { reuse } from '../internal/reuse'
3
3
  import type { SlotProps } from './slot'
4
4
  import type { EventsOf, UiContract } from './ui'
@@ -164,6 +164,29 @@ interface ValuePair {
164
164
  readonly right: unknown
165
165
  }
166
166
 
167
+ // A structural comparison is only meaningful over own enumerable keys when those
168
+ // keys are the whole value — true of JSON-shaped records, false of everything
169
+ // carrying its state anywhere else.
170
+ const isPlainRecord = (candidate: unknown): candidate is Record<string, unknown> => {
171
+ if (!isRecord(candidate)) {
172
+ return false
173
+ }
174
+ const prototype = Object.getPrototypeOf(candidate)
175
+ return prototype === Object.prototype || prototype === null
176
+ }
177
+
178
+ const recordEntriesEqual = (
179
+ left: Record<string, unknown>,
180
+ right: Record<string, unknown>,
181
+ leftKeys: ReadonlyArray<string>,
182
+ ): boolean =>
183
+ leftKeys.length === Struct.keys(right).length &&
184
+ leftKeys.every(
185
+ (key) =>
186
+ Object.prototype.hasOwnProperty.call(right, key) &&
187
+ deepEquals({ left: left[key], right: right[key] }),
188
+ )
189
+
167
190
  const deepEquals = ({ left, right }: ValuePair): boolean => {
168
191
  if (Object.is(left, right)) {
169
192
  return true
@@ -174,19 +197,28 @@ const deepEquals = ({ left, right }: ValuePair): boolean => {
174
197
  left.every((element, index) => deepEquals({ left: element, right: right[index] }))
175
198
  )
176
199
  }
177
- if (isRecord(left) && isRecord(right)) {
178
- const leftKeys = Struct.keys(left)
179
- const rightKeys = Struct.keys(right)
180
- return (
181
- leftKeys.length === rightKeys.length &&
182
- leftKeys.every(
183
- (key) =>
184
- Object.prototype.hasOwnProperty.call(right, key) &&
185
- deepEquals({ left: left[key], right: right[key] }),
186
- )
187
- )
200
+ if (isPlainRecord(left) && isPlainRecord(right)) {
201
+ return recordEntriesEqual(left, right, Struct.keys(left))
202
+ }
203
+ // Not a plain record. Own enumerable keys describe nothing about a `Date`, `Map` or
204
+ // `Set` comparing by them calls every one of them empty, and so equal — so ask
205
+ // the value itself first.
206
+ if (Equal.equals(left, right)) {
207
+ return true
188
208
  }
189
- return false
209
+ // Effect containers say no whenever they wrap a freshly allocated record, because
210
+ // their own equality bottoms out in reference identity. They do carry their payload
211
+ // in own properties, so descend: two structurally equal `Option.some({…})` are the
212
+ // same frame. A value with no own keys (a `Date`, a `Map`) has already answered.
213
+ if (!isRecord(left) || !isRecord(right)) {
214
+ return false
215
+ }
216
+ const leftKeys = Struct.keys(left)
217
+ return (
218
+ leftKeys.length > 0 &&
219
+ Object.getPrototypeOf(left) === Object.getPrototypeOf(right) &&
220
+ recordEntriesEqual(left, right, leftKeys)
221
+ )
190
222
  }
191
223
 
192
224
  export const structureEquals = (
package/src/compose/ui.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FunctionComponent } from 'react'
2
- import { Context, type Schema } from 'effect'
2
+ import { Context, GlobalValue, MutableRef, type Schema } from 'effect'
3
3
  import type { AST } from 'effect/SchemaAST'
4
4
  import { type Manifest, definitionClass } from '../definition/definition'
5
5
  import type { Trigger } from '../ui/trigger'
@@ -139,6 +139,18 @@ type UiDefinitionStatics<
139
139
  typeof UiTypeId | 'manifest' | 'impl' | 'provider' | 'capture'
140
140
  >
141
141
 
142
+ // Same rationale as `slot()` and `Composition.make`: two independently authored
143
+ // definitions that happen to share a name are two contracts, not one. Keying the
144
+ // runtime tag by name alone collapses them, and the last `provide` wins silently.
145
+ // The wire still identifies a view by `manifest.name`, which is untouched.
146
+ const uiDefinitionSequence: MutableRef.MutableRef<number> = GlobalValue.globalValue(
147
+ Symbol.for('reform/UiDefinitionSequence'),
148
+ () => MutableRef.make(0),
149
+ )
150
+
151
+ const nextUiTagKey = (name: string): string =>
152
+ `reform/ui/${name}/${MutableRef.incrementAndGet(uiDefinitionSequence)}`
153
+
142
154
  const buildUi = <
143
155
  N extends string,
144
156
  C extends UiContract,
@@ -147,8 +159,9 @@ const buildUi = <
147
159
  name: N,
148
160
  manifest: M,
149
161
  ): UiClass<C, N> & { readonly manifest: M } => {
150
- const impl = Context.GenericTag<UiService<N>, ViewImpl<C>>(`reform/ui/${name}`)
151
- const provider = Context.GenericTag<UiService<N>, CallableView>(`reform/ui/${name}`)
162
+ const key = nextUiTagKey(name)
163
+ const impl = Context.GenericTag<UiService<N>, ViewImpl<C>>(key)
164
+ const provider = Context.GenericTag<UiService<N>, CallableView>(key)
152
165
  const contract = definitionClass<UiClass<C, N> & { readonly manifest: M }>({
153
166
  [UiTypeId]: UiTypeId,
154
167
  manifest,
@@ -213,8 +226,12 @@ export const make = <U extends AnyUi>(
213
226
  contract: U,
214
227
  view: ViewImpl<Contract<U>>,
215
228
  ): MadeView<Contract<U>, UiName<U>, U> => {
216
- // oxlint-disable-next-line reform-rules/no-object-assign -- `view` is callable; spread would drop callability, so its reflection statics are attached in place
217
- const made: MadeView<Contract<U>, UiName<U>, U> = Object.assign(view, {
229
+ // Branded on a fresh wrapper rather than on `view` itself: the caller's function is
230
+ // theirs, and attaching in place lets a second `Ui.make` over the same view rebrand
231
+ // the first contract's binding out from under it.
232
+ const wrapped: ViewImpl<Contract<U>> = (props, slots, events) => view(props, slots, events)
233
+ // oxlint-disable-next-line reform-rules/no-object-assign -- `wrapped` is callable; spread would drop callability, so its reflection statics are attached in place
234
+ const made: MadeView<Contract<U>, UiName<U>, U> = Object.assign(wrapped, {
218
235
  [UiViewContract]: contract,
219
236
  capture: <Result>(visit: MadeViewCapture<Result>): Result => visit(made),
220
237
  })
@@ -1,9 +1,10 @@
1
1
  import { Effect, Layer, MutableRef, Option, PubSub, Runtime, Schema } from 'effect'
2
2
  import { type Manifest, definitionClass } from '../definition/definition'
3
- import { resolveScheduler } from '../internal/scheduler'
3
+ import { resolveScheduler, withInlineNotifications } from '../internal/scheduler'
4
4
  import { Bus, publish, type Tagged } from '../runtime/bus'
5
+ import { applyReducers, Reducers } from '../runtime/loop'
5
6
  import type { Source } from '../state/token'
6
- import type { Trigger } from '../ui/trigger'
7
+ import { taggedTrigger, type Trigger } from '../ui/trigger'
7
8
 
8
9
  export type EventOf<N extends string, P> = Tagged & { readonly _tag: N } & P
9
10
 
@@ -52,12 +53,40 @@ export const make = <const N extends string, P, Encoded>(
52
53
  // (e.g. a real Option, whose _tag lives on the prototype).
53
54
  const validatePayload = Schema.validateSync(schema)
54
55
  const buildUnknown = (payload: unknown): Tagged => build(validatePayload(payload))
56
+ // A trigger is the UI's edge of the loop: it runs inside a discrete DOM event, and
57
+ // React decides what the DOM should look like before that event finishes. Deferring
58
+ // the reducer to the drain fiber means the props React reads back are one turn stale,
59
+ // which is what rewrites a controlled input's value and drops the caret to the end.
60
+ // So reducers for this one event run here, before the trigger returns, and the
61
+ // envelope goes out marked `reduced` for the channel/procedure half. Every batched
62
+ // path — `Event.dispatch`, procedure fan-out, streams — is untouched and still
63
+ // coalesces on the drain loop.
55
64
  const trigger = Effect.gen(function* () {
56
65
  const bus = yield* Bus
66
+ // Optional: a bus can be provided without a reducer registry (bare harnesses,
67
+ // some test layers), and those keep the original deferred behaviour.
68
+ const reducers = yield* Effect.serviceOption(Reducers)
57
69
  const runtime = yield* Effect.runtime<never>()
58
- return (payload: P) => {
59
- Runtime.runSync(runtime)(PubSub.publish(bus, { priority: 'High', event: build(payload) }))
60
- }
70
+ return taggedTrigger(name, (payload: P) => {
71
+ const built = build(payload)
72
+ const reduced = Option.match(reducers, {
73
+ onNone: () => false,
74
+ onSome: (registry) =>
75
+ withInlineNotifications(() => {
76
+ const failures = applyReducers({ registry, event: built })
77
+ failures.forEach((failure) =>
78
+ Effect.runSync(
79
+ Effect.logError(
80
+ `reform: reducer threw handling '${failure.event._tag}'`,
81
+ failure.error,
82
+ ),
83
+ ),
84
+ )
85
+ return true
86
+ }),
87
+ })
88
+ Runtime.runSync(runtime)(PubSub.publish(bus, { priority: 'High', event: built, reduced }))
89
+ })
61
90
  })
62
91
  const event: EventClass<N, P> = definitionClass<EventClass<N, P>>({
63
92
  manifest: { kind: 'Event' as const, name, schema },
@@ -0,0 +1,66 @@
1
+ import { Effect, Layer, MutableRef, Schema as S } from 'effect'
2
+ import { it } from '@effect/vitest'
3
+ import { describe, expect } from 'vitest'
4
+ import { until } from '../testkit/flight.testkit'
5
+ import { Engine } from '../runtime/loop'
6
+ import { publish } from '../runtime/bus'
7
+ import * as Event from './event'
8
+ import * as Reducer from '../reducer/reducer'
9
+ import * as State from '../state/state'
10
+
11
+ class Counter extends State.make('sync-trigger-counter', S.Number) {}
12
+ class Bumped extends Event.make('SyncTriggerBumped', S.Struct({ to: S.Number })) {}
13
+ class Fold extends Reducer.make('SyncTriggerFold', {
14
+ states: [Counter],
15
+ events: [Bumped],
16
+ }) {}
17
+
18
+ const makeLayer = (runs: MutableRef.MutableRef<number>) =>
19
+ Reducer.live(Fold, (_current: number, event: { readonly to: number }) => {
20
+ MutableRef.set(runs, MutableRef.get(runs) + 1)
21
+ return event.to
22
+ }).pipe(Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)))
23
+
24
+ describe('synchronous trigger path', () => {
25
+ it.live('applies a reducer exactly once — the drain must not re-apply it', () => {
26
+ const runs = MutableRef.make(0)
27
+ return Effect.gen(function* () {
28
+ const bump = yield* Event.trigger(Bumped)
29
+ const store = yield* Counter.store
30
+
31
+ yield* Effect.sync(() => bump({ to: 7 }))
32
+
33
+ // Already applied before the trigger returned, with no turn of the loop.
34
+ expect(MutableRef.get(runs)).toBe(1)
35
+ expect(store.get()).toBe(7)
36
+
37
+ // The envelope still reaches the drain for channels and procedures; give it
38
+ // several turns and prove the reducer was not run a second time.
39
+ yield* until(
40
+ () => MutableRef.get(runs),
41
+ (count) => count > 1,
42
+ 20,
43
+ )
44
+ expect(MutableRef.get(runs)).toBe(1)
45
+ expect(store.get()).toBe(7)
46
+ }).pipe(Effect.provide(makeLayer(runs)))
47
+ })
48
+
49
+ it.live('leaves Event.dispatch on the batched drain', () => {
50
+ const runs = MutableRef.make(0)
51
+ return Effect.gen(function* () {
52
+ const store = yield* Counter.store
53
+
54
+ yield* publish('Normal', Event.construct(Bumped, { to: 3 }))
55
+
56
+ // Nothing synchronous about this path: it is still the drain fiber's job.
57
+ expect(MutableRef.get(runs)).toBe(0)
58
+
59
+ yield* until(
60
+ () => store.get(),
61
+ (value) => value === 3,
62
+ )
63
+ expect(MutableRef.get(runs)).toBe(1)
64
+ }).pipe(Effect.provide(makeLayer(runs)))
65
+ })
66
+ })