@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
@@ -1,10 +1,12 @@
1
- import { Context, Effect, Layer, Option, Schema } from 'effect'
1
+ import { Context, Effect, Layer, MutableRef, Schema } from 'effect'
2
2
  import { type Manifest, definitionClass } from '../definition/definition'
3
+ import { makeKeyCanonicalizer } from '../internal/familyKeys'
3
4
  import { resolveScheduler, type Scheduler } from '../internal/scheduler'
4
5
  import { claimStateTag } from '../internal/stateRegistry'
5
6
  import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
6
7
  import { makeStore, type Store } from '../internal/store'
7
8
  import { readTracked } from '../internal/track'
9
+ import { microtask } from '../internal/platform'
8
10
 
9
11
  export const Tombstone: unique symbol = Symbol.for('reform/StateFamily/Tombstone')
10
12
  export type Tombstone = typeof Tombstone
@@ -28,56 +30,102 @@ const makeFamilyStore = <K, V>(
28
30
  onChange: () => void = () => {},
29
31
  ): FamilyStore<K, V> => {
30
32
  const entries = new Map<K, Store<V>>()
33
+ const keys = makeKeyCanonicalizer<K>()
31
34
  const evictWhenUnused = options.evictWhenUnused === true
32
- const subscribers = new Map<K, number>()
33
- const liveSubscriberCount = (key: K): number =>
34
- Option.getOrElse(Option.fromNullable(subscribers.get(key)), () => 0)
35
-
36
- const refCounted = (key: K, store: Store<V>): Store<V> => ({
35
+ // Stamps identify a store *instance*, not a key: `forget` lets one key hold a
36
+ // succession of stores, and a stale unsubscribe from an earlier one must not
37
+ // decrement or evict — the one that replaced it.
38
+ const stamps = new Map<K, number>()
39
+ const nextStamp = MutableRef.make(0)
40
+
41
+ // A forgotten key's store is orphaned — `at(key)` mints a fresh one — so its
42
+ // subscribers must be woken to re-read, or they hold a store nothing writes to
43
+ // again and never hear about the key another way.
44
+ const wakers = new Map<K, Set<() => void>>()
45
+ const wakersFor = (key: K): Set<() => void> => {
46
+ const existing = wakers.get(key)
47
+ if (existing !== undefined) {
48
+ return existing
49
+ }
50
+ const created = new Set<() => void>()
51
+ wakers.set(key, created)
52
+ return created
53
+ }
54
+ const wakeable = (key: K, store: Store<V>): Store<V> => ({
37
55
  ...store,
38
56
  subscribe: (listener) => {
39
- subscribers.set(key, liveSubscriberCount(key) + 1)
57
+ const registered = wakersFor(key)
58
+ registered.add(listener)
40
59
  const off = store.subscribe(listener)
41
- const released = { done: false }
42
60
  return () => {
43
- if (released.done) {
44
- return
45
- }
46
- released.done = true
61
+ registered.delete(listener)
47
62
  off()
48
- const remaining = (subscribers.get(key) ?? 1) - 1
49
- if (remaining > 0) {
50
- subscribers.set(key, remaining)
51
- return
52
- }
53
- subscribers.delete(key)
54
- queueMicrotask(() => {
55
- if (liveSubscriberCount(key) === 0) {
56
- entries.delete(key)
57
- }
58
- })
59
63
  }
60
64
  },
61
65
  })
66
+ const dropKey = (key: K): void => {
67
+ entries.delete(key)
68
+ stamps.delete(key)
69
+ keys.release(key)
70
+ const woken = wakers.get(key)
71
+ wakers.delete(key)
72
+ if (woken !== undefined && woken.size > 0) {
73
+ scheduler.schedule(woken)
74
+ }
75
+ }
76
+
77
+ const refCounted = (key: K, store: Store<V>, stamp: number): Store<V> => {
78
+ const count = MutableRef.make(0)
79
+ return {
80
+ ...store,
81
+ subscribe: (listener) => {
82
+ MutableRef.update(count, (current) => current + 1)
83
+ const off = store.subscribe(listener)
84
+ const released = { done: false }
85
+ return () => {
86
+ if (released.done) {
87
+ return
88
+ }
89
+ released.done = true
90
+ off()
91
+ MutableRef.update(count, (current) => Math.max(0, current - 1))
92
+ if (MutableRef.get(count) > 0) {
93
+ return
94
+ }
95
+ microtask(() => {
96
+ // Still unused, and still the store this key holds.
97
+ if (MutableRef.get(count) === 0 && stamps.get(key) === stamp) {
98
+ entries.delete(key)
99
+ stamps.delete(key)
100
+ keys.release(key)
101
+ if (wakersFor(key).size === 0) {
102
+ wakers.delete(key)
103
+ }
104
+ }
105
+ })
106
+ }
107
+ },
108
+ }
109
+ }
62
110
 
63
111
  return {
64
- at: (key) => {
112
+ at: (rawKey) => {
113
+ const key = keys.canonical(rawKey)
65
114
  const existing = entries.get(key)
66
115
  if (existing !== undefined) {
67
116
  return existing
68
117
  }
118
+ const stamp = MutableRef.updateAndGet(nextStamp, (current) => current + 1)
69
119
  const base = makeStore(seed(key), scheduler, onChange)
70
- const created = evictWhenUnused ? refCounted(key, base) : base
120
+ const created = wakeable(key, evictWhenUnused ? refCounted(key, base, stamp) : base)
71
121
  entries.set(key, created)
122
+ stamps.set(key, stamp)
72
123
  return created
73
124
  },
74
- forget: (key) => {
75
- entries.delete(key)
76
- subscribers.delete(key)
77
- },
125
+ forget: (key) => dropKey(keys.canonical(key)),
78
126
  clear: () => {
79
- entries.clear()
80
- subscribers.clear()
127
+ Array.from(entries.keys()).forEach(dropKey)
128
+ keys.reset()
81
129
  },
82
130
  size: () => entries.size,
83
131
  }
@@ -120,8 +168,10 @@ export interface StateFamilyClass<N extends string, K, V> {
120
168
  readonly manifest: StateFamilyManifest<N>
121
169
  readonly capture: <Result>(visit: StateFamilyCapture<Result>) => Result
122
170
  readonly store: Context.Tag<StateFamilyStore<N, K, V>, FamilyStore<K, V>>
123
- readonly decodeKeySync: (input: unknown) => K
124
- readonly decodeValueSync: (input: unknown) => V
171
+ // Type-side checks: keys and values reaching these are already-decoded domain
172
+ // values, which a transforming schema's decoder would reject.
173
+ readonly validateKeySync: (input: unknown) => K
174
+ readonly validateValueSync: (input: unknown) => V
125
175
  }
126
176
 
127
177
  export type FamilyName<F> = F extends StateFamilyClass<infer N, infer _K, infer _V> ? N : never
@@ -153,8 +203,8 @@ export const make = <const N extends string, K, KeyEncoded, V, ValueEncoded>(
153
203
  const family: StateFamilyClass<N, K, V> = definitionClass<StateFamilyClass<N, K, V>>({
154
204
  manifest,
155
205
  store,
156
- decodeKeySync: Schema.decodeUnknownSync(key),
157
- decodeValueSync: Schema.decodeUnknownSync(valueSchema),
206
+ validateKeySync: Schema.validateSync(key),
207
+ validateValueSync: Schema.validateSync(valueSchema),
158
208
  capture: <Result>(visit: StateFamilyCapture<Result>): Result => visit(family),
159
209
  })
160
210
  return family
@@ -173,7 +223,7 @@ export const live = <N extends string, K, V>(
173
223
  ): Layer.Layer<StateFamilyStore<N, K, V>> => {
174
224
  const seed = (key: K): V =>
175
225
  typeof initial === 'function'
176
- ? family.decodeValueSync(Reflect.apply(initial, undefined, [key]))
226
+ ? family.validateValueSync(Reflect.apply(initial, undefined, [key]))
177
227
  : initial
178
228
  return Layer.effect(
179
229
  family.store,
@@ -32,18 +32,36 @@ export type AnyStateGroup = StateGroupClass<ReadonlyArray<AnyState>>
32
32
 
33
33
  type DuplicateStateName<N extends string> = readonly ['reform: duplicate state name in group', N]
34
34
 
35
+ // TAIL-RECURSIVE ON PURPOSE, and the accumulator is the whole reason it is
36
+ // written this way. The obvious formulation puts the recursive call inside the
37
+ // tuple it returns -- `readonly [Checked, ...NoDuplicateNames<Tail, ...>]` --
38
+ // which is NOT a tail call, so TypeScript keeps a frame per member and the
39
+ // depth of this type is the SIZE OF THE GROUP. A group grows one state at a
40
+ // time and nothing warns, so the limit is reached by an append that is correct
41
+ // on its own: measured at a forty-member group, where two independent additions
42
+ // of one state each produced
43
+ // `error TS2589: Type instantiation is excessively deep and possibly infinite`
44
+ // at the `StateGroup.make` call and at no other site.
45
+ //
46
+ // With the result carried in `Acc` the recursive call IS the whole true branch,
47
+ // which TypeScript eliminates, and the ceiling goes from ~50 to ~1000.
35
48
  type NoDuplicateNames<
36
49
  Members extends ReadonlyArray<AnyState>,
37
50
  Seen extends string = never,
51
+ Acc extends ReadonlyArray<unknown> = readonly [],
38
52
  > = Members extends readonly [
39
53
  infer Head extends AnyState,
40
54
  ...infer Tail extends ReadonlyArray<AnyState>,
41
55
  ]
42
- ? readonly [
43
- StateName<Head> extends Seen ? DuplicateStateName<StateName<Head>> : Head,
44
- ...NoDuplicateNames<Tail, Seen | StateName<Head>>,
45
- ]
46
- : readonly []
56
+ ? NoDuplicateNames<
57
+ Tail,
58
+ Seen | StateName<Head>,
59
+ readonly [
60
+ ...Acc,
61
+ StateName<Head> extends Seen ? DuplicateStateName<StateName<Head>> : Head,
62
+ ]
63
+ >
64
+ : Acc
47
65
 
48
66
  export type GroupSeeds<G extends AnyStateGroup> =
49
67
  G extends StateGroupClass<infer Members>
@@ -113,7 +131,7 @@ export function live(
113
131
  const layers = group.members.map((member) =>
114
132
  member.capture<Layer.Layer<never>>((state) =>
115
133
  Layer.unwrapEffect(
116
- state.decodeUnknown(seeds[state.manifest.name]).pipe(
134
+ state.validate(seeds[state.manifest.name]).pipe(
117
135
  Effect.orDie,
118
136
  Effect.map((seed) => stateLive(state, seed)),
119
137
  ),
@@ -1,4 +1,5 @@
1
1
  import { Deferred, Duration, Effect, MutableRef, Option } from 'effect'
2
+ import { microtask } from '../internal/platform'
2
3
 
3
4
  // Coalescing/cancellation semantics are only meaningful while a query is still
4
5
  // in flight. Tests used to build that overlap by racing wall-clock — a 25ms
@@ -15,7 +16,7 @@ export const tick = (ms = 10): Effect.Effect<void> => Effect.sleep(Duration.mill
15
16
  // starved by host load the way a 10ms budget can. Use this to let the graph
16
17
  // quiesce before asserting that something did NOT happen.
17
18
  export const flush = (turns = 3): Effect.Effect<void> =>
18
- Effect.promise(() => new Promise<void>((resolve) => queueMicrotask(resolve))).pipe(
19
+ Effect.promise(() => new Promise<void>((resolve) => microtask(resolve))).pipe(
19
20
  Effect.repeatN(Math.max(turns - 1, 0)),
20
21
  )
21
22
 
@@ -0,0 +1,46 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Effect, Schema as S } from 'effect'
3
+ import * as Wire from './tree'
4
+ import type { WireNode } from './tree'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // BUG 3 — `Wire.deepEqual` compares key COUNT and then reads `right[key]`, so
8
+ // two prop records with the same number of keys but different key sets are
9
+ // reported unchanged whenever the differing keys hold `undefined`.
10
+ //
11
+ // `compose/structure.ts`'s `deepEquals` guards this with
12
+ // `Object.prototype.hasOwnProperty.call(right, key)`; `wire/tree.ts` does not.
13
+ // The gap is reachable: `Schema.encodeUnknown` of a plain `S.optional` field
14
+ // keeps the key when its value is `undefined` (asserted below), so the encoded
15
+ // props a composition hands the wire really can carry `undefined`-valued keys.
16
+ // The consequence is a dropped frame: `diff` emits no patch, so the client
17
+ // keeps rendering the previous props forever.
18
+ // ---------------------------------------------------------------------------
19
+ it.effect('wire diff reports a prop record whose key set changed', () =>
20
+ Effect.gen(function* () {
21
+ const FormProps = S.Struct({
22
+ draftTitle: S.optional(S.String),
23
+ draftBody: S.optional(S.String),
24
+ id: S.String,
25
+ })
26
+ const before = yield* S.encodeUnknown(FormProps)({ draftTitle: undefined, id: 'post-1' })
27
+ const after = yield* S.encodeUnknown(FormProps)({ draftBody: undefined, id: 'post-1' })
28
+
29
+ // Encoding really does keep an `undefined`-valued optional key.
30
+ expect(Object.keys(before)).toEqual(['draftTitle', 'id'])
31
+ expect(Object.keys(after)).toEqual(['draftBody', 'id'])
32
+
33
+ const node = (value: unknown): WireNode => ({
34
+ id: 'form',
35
+ name: 'FormUi',
36
+ parentId: null,
37
+ childIndex: 0,
38
+ slot: null,
39
+ key: null,
40
+ props: [{ _tag: 'Data', name: 'form', value }],
41
+ })
42
+
43
+ // `draftTitle` was dropped and `draftBody` added — the node changed.
44
+ expect(Wire.diff([node(before)], [node(after)])).toHaveLength(1)
45
+ }),
46
+ )
package/src/wire/tree.ts CHANGED
@@ -40,7 +40,15 @@ const recordsEqual = (left: Record<string, unknown>, right: Record<string, unkno
40
40
  const rightKeys = Rec.keys(right)
41
41
  return (
42
42
  leftKeys.length === rightKeys.length &&
43
- leftKeys.every((key) => deepEqual({ left: left[key], right: right[key] }))
43
+ // Presence, not just value: two records of the same size whose keys differ both
44
+ // read `undefined` for the other's key and would compare equal. An encoded
45
+ // `S.optional` field keeps its key when its value is undefined, so a frame can
46
+ // really swap one such key for another and diff to no patches at all.
47
+ leftKeys.every(
48
+ (key) =>
49
+ Object.prototype.hasOwnProperty.call(right, key) &&
50
+ deepEqual({ left: left[key], right: right[key] }),
51
+ )
44
52
  )
45
53
  }
46
54