@playfast/reform 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) 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.map +1 -1
  28. package/dist/event/event.js +23 -2
  29. package/dist/event/event.js.map +1 -1
  30. package/dist/internal/familyKeys.d.ts +16 -0
  31. package/dist/internal/familyKeys.d.ts.map +1 -0
  32. package/dist/internal/familyKeys.js +35 -0
  33. package/dist/internal/familyKeys.js.map +1 -0
  34. package/dist/internal/platform.d.ts +43 -0
  35. package/dist/internal/platform.d.ts.map +1 -0
  36. package/dist/internal/platform.js +68 -0
  37. package/dist/internal/platform.js.map +1 -0
  38. package/dist/internal/queryDriver.d.ts.map +1 -1
  39. package/dist/internal/queryDriver.js +88 -64
  40. package/dist/internal/queryDriver.js.map +1 -1
  41. package/dist/internal/queryDriverConsume.d.ts +9 -0
  42. package/dist/internal/queryDriverConsume.d.ts.map +1 -0
  43. package/dist/internal/queryDriverConsume.js +16 -0
  44. package/dist/internal/queryDriverConsume.js.map +1 -0
  45. package/dist/internal/queryDriverHydrate.d.ts.map +1 -1
  46. package/dist/internal/queryDriverHydrate.js +9 -3
  47. package/dist/internal/queryDriverHydrate.js.map +1 -1
  48. package/dist/internal/queryDriverPersist.d.ts +16 -0
  49. package/dist/internal/queryDriverPersist.d.ts.map +1 -0
  50. package/dist/internal/queryDriverPersist.js +15 -0
  51. package/dist/internal/queryDriverPersist.js.map +1 -0
  52. package/dist/internal/queryDriverPersistWarn.d.ts +12 -0
  53. package/dist/internal/queryDriverPersistWarn.d.ts.map +1 -0
  54. package/dist/internal/queryDriverPersistWarn.js +31 -0
  55. package/dist/internal/queryDriverPersistWarn.js.map +1 -0
  56. package/dist/internal/queryDriverSettle.d.ts +23 -0
  57. package/dist/internal/queryDriverSettle.d.ts.map +1 -0
  58. package/dist/internal/queryDriverSettle.js +52 -0
  59. package/dist/internal/queryDriverSettle.js.map +1 -0
  60. package/dist/internal/queryDriverStore.d.ts +2 -28
  61. package/dist/internal/queryDriverStore.d.ts.map +1 -1
  62. package/dist/internal/queryDriverStore.js +5 -29
  63. package/dist/internal/queryDriverStore.js.map +1 -1
  64. package/dist/internal/queryDriverTypes.d.ts +3 -0
  65. package/dist/internal/queryDriverTypes.d.ts.map +1 -1
  66. package/dist/internal/scheduler.d.ts +1 -0
  67. package/dist/internal/scheduler.d.ts.map +1 -1
  68. package/dist/internal/scheduler.js +69 -6
  69. package/dist/internal/scheduler.js.map +1 -1
  70. package/dist/internal.d.ts +1 -0
  71. package/dist/internal.d.ts.map +1 -1
  72. package/dist/internal.js +1 -0
  73. package/dist/internal.js.map +1 -1
  74. package/dist/reducer/reducer.js +3 -3
  75. package/dist/reducer/reducer.js.map +1 -1
  76. package/dist/remote/pendingQueue.d.ts +4 -2
  77. package/dist/remote/pendingQueue.d.ts.map +1 -1
  78. package/dist/remote/pendingQueue.js +20 -4
  79. package/dist/remote/pendingQueue.js.map +1 -1
  80. package/dist/remote/remoteState.d.ts.map +1 -1
  81. package/dist/remote/remoteState.js +27 -14
  82. package/dist/remote/remoteState.js.map +1 -1
  83. package/dist/remote/remoteStateSend.d.ts +3 -1
  84. package/dist/remote/remoteStateSend.d.ts.map +1 -1
  85. package/dist/remote/remoteStateSend.js +19 -3
  86. package/dist/remote/remoteStateSend.js.map +1 -1
  87. package/dist/remote/remoteStateTypes.d.ts +3 -0
  88. package/dist/remote/remoteStateTypes.d.ts.map +1 -1
  89. package/dist/runtime/bus.d.ts +5 -0
  90. package/dist/runtime/bus.d.ts.map +1 -1
  91. package/dist/runtime/bus.js +1 -1
  92. package/dist/runtime/bus.js.map +1 -1
  93. package/dist/runtime/capturedAppRuntime.d.ts.map +1 -1
  94. package/dist/runtime/capturedAppRuntime.js +37 -51
  95. package/dist/runtime/capturedAppRuntime.js.map +1 -1
  96. package/dist/runtime/eventBudget.d.ts.map +1 -1
  97. package/dist/runtime/eventBudget.js +2 -1
  98. package/dist/runtime/eventBudget.js.map +1 -1
  99. package/dist/runtime/featureMount.d.ts +34 -2
  100. package/dist/runtime/featureMount.d.ts.map +1 -1
  101. package/dist/runtime/featureMount.js +66 -2
  102. package/dist/runtime/featureMount.js.map +1 -1
  103. package/dist/runtime/loop.d.ts +12 -0
  104. package/dist/runtime/loop.d.ts.map +1 -1
  105. package/dist/runtime/loop.js +8 -5
  106. package/dist/runtime/loop.js.map +1 -1
  107. package/dist/state/state.d.ts +2 -2
  108. package/dist/state/state.d.ts.map +1 -1
  109. package/dist/state/state.js +2 -2
  110. package/dist/state/state.js.map +1 -1
  111. package/dist/state/stateFamily.d.ts +2 -2
  112. package/dist/state/stateFamily.d.ts.map +1 -1
  113. package/dist/state/stateFamily.js +80 -32
  114. package/dist/state/stateFamily.js.map +1 -1
  115. package/dist/state/stateGroup.d.ts +5 -5
  116. package/dist/state/stateGroup.d.ts.map +1 -1
  117. package/dist/state/stateGroup.js +1 -1
  118. package/dist/state/stateGroup.js.map +1 -1
  119. package/dist/testkit/flight.testkit.d.ts.map +1 -1
  120. package/dist/testkit/flight.testkit.js +2 -1
  121. package/dist/testkit/flight.testkit.js.map +1 -1
  122. package/dist/wire/tree.d.ts.map +1 -1
  123. package/dist/wire/tree.js +6 -1
  124. package/dist/wire/tree.js.map +1 -1
  125. package/package.json +1 -1
  126. package/src/calc/asyncCalc-key-attribution.test.ts +182 -0
  127. package/src/calc/asyncCalc-registry-name.test.ts +82 -0
  128. package/src/calc/asyncCalc.superseded-commit.test.ts +162 -0
  129. package/src/calc/asyncCalc.test.ts +35 -7
  130. package/src/calc/asyncCalc.ts +33 -10
  131. package/src/calc/asyncCalcTypes.ts +6 -0
  132. package/src/calc/calcFamily.ts +81 -34
  133. package/src/calc/family-identity.test.ts +231 -0
  134. package/src/calc/queryState.ts +36 -5
  135. package/src/channel/channel-throttle-frames.test.ts +163 -0
  136. package/src/channel/channel.ts +5 -0
  137. package/src/compose/compose-identity.test.ts +175 -0
  138. package/src/compose/composition.ts +8 -4
  139. package/src/compose/structure.ts +45 -13
  140. package/src/compose/ui.ts +22 -5
  141. package/src/event/event.ts +31 -2
  142. package/src/event/syncTrigger.test.ts +66 -0
  143. package/src/internal/familyKeys.ts +41 -0
  144. package/src/internal/platform.test.ts +55 -0
  145. package/src/internal/platform.ts +72 -0
  146. package/src/internal/queryDriver-defect-attribution.test.ts +107 -0
  147. package/src/internal/queryDriver-persist-key.test.ts +118 -0
  148. package/src/internal/queryDriver.persistence-staleness.test.ts +209 -0
  149. package/src/internal/queryDriver.ts +92 -80
  150. package/src/internal/queryDriverConsume.ts +42 -0
  151. package/src/internal/queryDriverHydrate.ts +9 -3
  152. package/src/internal/queryDriverPersist.ts +42 -0
  153. package/src/internal/queryDriverPersistWarn.ts +61 -0
  154. package/src/internal/queryDriverSettle.ts +85 -0
  155. package/src/internal/queryDriverStore.ts +9 -74
  156. package/src/internal/queryDriverTypes.ts +10 -0
  157. package/src/internal/queryState-and-scheduler-audit.test.ts +170 -0
  158. package/src/internal/scheduler-multi-source-wake.test.ts +111 -0
  159. package/src/internal/scheduler.ts +80 -6
  160. package/src/internal.ts +1 -0
  161. package/src/reducer/reducer.ts +3 -3
  162. package/src/remote/pendingQueue.ts +35 -5
  163. package/src/remote/remoteState.intent-key-stamp.test.ts +207 -0
  164. package/src/remote/remoteState.key-attribution.test.ts +174 -0
  165. package/src/remote/remoteState.ts +27 -15
  166. package/src/remote/remoteStateSend.ts +29 -8
  167. package/src/remote/remoteStateTypes.ts +7 -0
  168. package/src/runtime/bus.ts +6 -1
  169. package/src/runtime/capturedAppRuntime.ts +134 -110
  170. package/src/runtime/eventBudget.ts +2 -1
  171. package/src/runtime/feature-mount-ownership.test.ts +539 -0
  172. package/src/runtime/featureMount.ts +110 -3
  173. package/src/runtime/loop.ts +29 -6
  174. package/src/runtime/mount-inflight-ownership.test.ts +361 -0
  175. package/src/runtime/mount-scope-hardening.test.ts +440 -0
  176. package/src/runtime/runtime.schema-identity.test.ts +188 -0
  177. package/src/state/state.ts +8 -4
  178. package/src/state/stateFamily.ts +86 -36
  179. package/src/state/stateGroup.ts +24 -6
  180. package/src/testkit/flight.testkit.ts +2 -1
  181. package/src/wire/tree-key-presence.test.ts +46 -0
  182. package/src/wire/tree.ts +9 -1
@@ -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,7 +1,8 @@
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
7
  import type { Trigger } from '../ui/trigger'
7
8
 
@@ -52,11 +53,39 @@ 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
70
  return (payload: P) => {
59
- Runtime.runSync(runtime)(PubSub.publish(bus, { priority: 'High', event: build(payload) }))
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 }))
60
89
  }
61
90
  })
62
91
  const event: EventClass<N, P> = definitionClass<EventClass<N, P>>({
@@ -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
+ })
@@ -0,0 +1,41 @@
1
+ import { MutableHashMap, Option, Predicate } from 'effect'
2
+
3
+ export interface KeyCanonicalizer<K> {
4
+ readonly canonical: (key: K) => K
5
+ readonly release: (key: K) => void
6
+ readonly reset: () => void
7
+ }
8
+
9
+ /**
10
+ * A family indexes its members in a plain `Map`, which compares keys by reference. A
11
+ * composite key — `S.Data(S.Struct({ board, row }))`, the value-typed key Effect makes
12
+ * canonical — is decoded fresh on every lookup, so a plain `Map` mints a new member per
13
+ * lookup and strands the one before it: the fold reads its seed forever and the family
14
+ * grows without bound. Everything else in reform compares values with `Equal.equals`;
15
+ * this makes family keys agree. Primitive keys are their own canonical form and never
16
+ * touch the table, so the hot path is unchanged.
17
+ */
18
+ export const makeKeyCanonicalizer = <K>(): KeyCanonicalizer<K> => {
19
+ const table = MutableHashMap.empty<K, K>()
20
+ return {
21
+ canonical: (key) => {
22
+ if (!Predicate.isObject(key)) {
23
+ return key
24
+ }
25
+ const known = MutableHashMap.get(table, key)
26
+ if (Option.isSome(known)) {
27
+ return known.value
28
+ }
29
+ MutableHashMap.set(table, key, key)
30
+ return key
31
+ },
32
+ release: (key) => {
33
+ if (Predicate.isObject(key)) {
34
+ MutableHashMap.remove(table, key)
35
+ }
36
+ },
37
+ reset: () => {
38
+ MutableHashMap.clear(table)
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,55 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest'
2
+ import { microtask, operationId } from './platform'
3
+
4
+ // A host without them is the case these exist for, and it is not reachable by
5
+ // reading the code: on every runtime the suite runs on, both globals are there
6
+ // and the fallback branch never executes. Removing them is the only way to
7
+ // measure it.
8
+ afterEach(() => {
9
+ vi.unstubAllGlobals()
10
+ })
11
+
12
+ describe('microtask', () => {
13
+ test('runs the callback where the host has queueMicrotask', async () => {
14
+ const ran = await new Promise<boolean>((resolve) => {
15
+ microtask(() => resolve(true))
16
+ })
17
+ expect(ran).toBe(true)
18
+ })
19
+
20
+ test('runs the callback where the host has none', async () => {
21
+ vi.stubGlobal('queueMicrotask', undefined)
22
+ const ran = await new Promise<boolean>((resolve) => {
23
+ microtask(() => resolve(true))
24
+ })
25
+ expect(ran).toBe(true)
26
+ })
27
+
28
+ test('does not run the callback synchronously', async () => {
29
+ vi.stubGlobal('queueMicrotask', undefined)
30
+ const order: string[] = []
31
+ microtask(() => order.push('ran'))
32
+
33
+ // The whole of what "microtask" claims, stated without a timer: not during
34
+ // this turn, and by the time a couple of already-resolved promises have
35
+ // been awaited. A `setTimeout` comparison would say the same thing and is
36
+ // banned in tests here, because it settles on the host clock.
37
+ expect(order).toEqual([])
38
+ await Promise.resolve()
39
+ await Promise.resolve()
40
+ expect(order).toEqual(['ran'])
41
+ })
42
+ })
43
+
44
+ describe('operationId', () => {
45
+ test('mints ids that differ where the host has crypto', () => {
46
+ expect(new Set([operationId(), operationId(), operationId()]).size).toBe(3)
47
+ })
48
+
49
+ test('mints ids that differ where the host has none', () => {
50
+ vi.stubGlobal('crypto', undefined)
51
+ // Same millisecond by construction, so the counter is the only thing
52
+ // separating these three and the assertion is about the counter.
53
+ expect(new Set([operationId(), operationId(), operationId()]).size).toBe(3)
54
+ })
55
+ })
@@ -0,0 +1,72 @@
1
+ import { MutableRef } from 'effect'
2
+
3
+ /**
4
+ * The two host globals reform reaches for, behind a check.
5
+ *
6
+ * The core is renderer-neutral and the React package peers on `react` alone, so
7
+ * a scene is expected to run wherever a React reconciler does — including hosts
8
+ * that are neither a browser nor Node. Two globals were reached for directly and
9
+ * neither is guaranteed off the web: measured on `@react-gpui/core`, whose
10
+ * deno_core host installs `console` and the timers and nothing else, a scene
11
+ * dies at the first scheduler tick with `queueMicrotask is not defined`.
12
+ *
13
+ * Both fall back rather than throw, because the property each is used for
14
+ * survives the fallback: a resolved promise is a microtask by definition, and an
15
+ * operation id has to be unique among the ids one runtime mints.
16
+ */
17
+
18
+ /**
19
+ * Run `callback` on a microtask.
20
+ *
21
+ * `queueMicrotask` where the host has it, a resolved promise where it does not.
22
+ * The two differ in one way that matters, which is why the check is kept rather
23
+ * than always taking the promise: a callback that throws inside `queueMicrotask`
24
+ * reaches the host's global error handler, where the same callback inside
25
+ * `.then` becomes an unhandled rejection nobody sees. The rethrow below puts the
26
+ * fallback back on the first of those.
27
+ */
28
+ export const microtask = (callback: () => void): void => {
29
+ if (typeof queueMicrotask === 'function') {
30
+ queueMicrotask(callback)
31
+ return
32
+ }
33
+ // oxlint-disable-next-line reform-rules/no-promise-static-methods, reform-rules/no-promise-methods -- a microtask is the primitive being polyfilled here; Effect.succeed is not one, and this branch exists precisely because the host has no scheduling primitive to reach for
34
+ void Promise.resolve()
35
+ .then(callback)
36
+ .catch((error: unknown) => {
37
+ // oxlint-disable-next-line reform-rules/no-set-timeout-interval -- rethrowing on a macrotask is what reaches the host's global error handler, which is the semantics `queueMicrotask` has and a rejected promise does not
38
+ setTimeout(() => {
39
+ // oxlint-disable-next-line reform-rules/no-throw -- only way to surface a listener defect to the host's global error handler, as internal/scheduler.ts does for the same reason
40
+ throw error
41
+ })
42
+ })
43
+ }
44
+
45
+ const RADIX = 36
46
+
47
+ const minted = MutableRef.make(0)
48
+
49
+ /**
50
+ * An id no other operation of this runtime shares.
51
+ *
52
+ * `crypto.randomUUID` where the host has it, because an id that leaves the
53
+ * process should not be guessable from another one. Where it does not, the
54
+ * COUNTER carries uniqueness — every id this runtime mints differs from every
55
+ * other, which is the whole of what a `queued`/`settled` pairing needs — and the
56
+ * clock only separates two runtimes started at different milliseconds. Two
57
+ * started inside one millisecond can agree, so a host that sends these ids
58
+ * somewhere they meet another runtime's should install `crypto.randomUUID`
59
+ * rather than rely on this.
60
+ *
61
+ * `Math.random` is deliberately absent and should not be added: this package
62
+ * forbids it in favour of Effect's `Random`, and a service cannot be reached
63
+ * from a function the scheduler calls synchronously.
64
+ */
65
+ export const operationId = (): string => {
66
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
67
+ return crypto.randomUUID()
68
+ }
69
+ MutableRef.update(minted, (count) => count + 1)
70
+ // oxlint-disable-next-line reform-rules/no-date-constructor -- DateTime is an Effect and this mint is synchronous; the clock is only a tiebreak between runtimes here, never read as a time
71
+ return `op-${Date.now().toString(RADIX)}-${MutableRef.get(minted).toString(RADIX)}`
72
+ }