@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.
- package/README.md +15 -6
- package/dist/calc/asyncCalc.d.ts +3 -1
- package/dist/calc/asyncCalc.d.ts.map +1 -1
- package/dist/calc/asyncCalc.js +15 -5
- package/dist/calc/asyncCalc.js.map +1 -1
- package/dist/calc/asyncCalcTypes.d.ts +2 -0
- package/dist/calc/asyncCalcTypes.d.ts.map +1 -1
- package/dist/calc/calcFamily.d.ts.map +1 -1
- package/dist/calc/calcFamily.js +77 -34
- package/dist/calc/calcFamily.js.map +1 -1
- package/dist/calc/queryState.d.ts +3 -1
- package/dist/calc/queryState.d.ts.map +1 -1
- package/dist/calc/queryState.js +31 -9
- package/dist/calc/queryState.js.map +1 -1
- package/dist/channel/channel.d.ts.map +1 -1
- package/dist/channel/channel.js +6 -1
- package/dist/channel/channel.js.map +1 -1
- package/dist/compose/composition.d.ts.map +1 -1
- package/dist/compose/composition.js +7 -1
- package/dist/compose/composition.js.map +1 -1
- package/dist/compose/structure.d.ts.map +1 -1
- package/dist/compose/structure.js +33 -8
- package/dist/compose/structure.js.map +1 -1
- package/dist/compose/ui.d.ts.map +1 -1
- package/dist/compose/ui.js +16 -5
- package/dist/compose/ui.js.map +1 -1
- package/dist/event/event.d.ts.map +1 -1
- package/dist/event/event.js +23 -2
- package/dist/event/event.js.map +1 -1
- package/dist/internal/familyKeys.d.ts +16 -0
- package/dist/internal/familyKeys.d.ts.map +1 -0
- package/dist/internal/familyKeys.js +35 -0
- package/dist/internal/familyKeys.js.map +1 -0
- package/dist/internal/platform.d.ts +43 -0
- package/dist/internal/platform.d.ts.map +1 -0
- package/dist/internal/platform.js +68 -0
- package/dist/internal/platform.js.map +1 -0
- package/dist/internal/queryDriver.d.ts.map +1 -1
- package/dist/internal/queryDriver.js +88 -64
- package/dist/internal/queryDriver.js.map +1 -1
- package/dist/internal/queryDriverConsume.d.ts +9 -0
- package/dist/internal/queryDriverConsume.d.ts.map +1 -0
- package/dist/internal/queryDriverConsume.js +16 -0
- package/dist/internal/queryDriverConsume.js.map +1 -0
- package/dist/internal/queryDriverHydrate.d.ts.map +1 -1
- package/dist/internal/queryDriverHydrate.js +9 -3
- package/dist/internal/queryDriverHydrate.js.map +1 -1
- package/dist/internal/queryDriverPersist.d.ts +16 -0
- package/dist/internal/queryDriverPersist.d.ts.map +1 -0
- package/dist/internal/queryDriverPersist.js +15 -0
- package/dist/internal/queryDriverPersist.js.map +1 -0
- package/dist/internal/queryDriverPersistWarn.d.ts +12 -0
- package/dist/internal/queryDriverPersistWarn.d.ts.map +1 -0
- package/dist/internal/queryDriverPersistWarn.js +31 -0
- package/dist/internal/queryDriverPersistWarn.js.map +1 -0
- package/dist/internal/queryDriverSettle.d.ts +23 -0
- package/dist/internal/queryDriverSettle.d.ts.map +1 -0
- package/dist/internal/queryDriverSettle.js +52 -0
- package/dist/internal/queryDriverSettle.js.map +1 -0
- package/dist/internal/queryDriverStore.d.ts +2 -28
- package/dist/internal/queryDriverStore.d.ts.map +1 -1
- package/dist/internal/queryDriverStore.js +5 -29
- package/dist/internal/queryDriverStore.js.map +1 -1
- package/dist/internal/queryDriverTypes.d.ts +3 -0
- package/dist/internal/queryDriverTypes.d.ts.map +1 -1
- package/dist/internal/scheduler.d.ts +1 -0
- package/dist/internal/scheduler.d.ts.map +1 -1
- package/dist/internal/scheduler.js +69 -6
- package/dist/internal/scheduler.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/reducer/reducer.js +3 -3
- package/dist/reducer/reducer.js.map +1 -1
- package/dist/remote/pendingQueue.d.ts +4 -2
- package/dist/remote/pendingQueue.d.ts.map +1 -1
- package/dist/remote/pendingQueue.js +20 -4
- package/dist/remote/pendingQueue.js.map +1 -1
- package/dist/remote/remoteState.d.ts.map +1 -1
- package/dist/remote/remoteState.js +27 -14
- package/dist/remote/remoteState.js.map +1 -1
- package/dist/remote/remoteStateSend.d.ts +3 -1
- package/dist/remote/remoteStateSend.d.ts.map +1 -1
- package/dist/remote/remoteStateSend.js +19 -3
- package/dist/remote/remoteStateSend.js.map +1 -1
- package/dist/remote/remoteStateTypes.d.ts +3 -0
- package/dist/remote/remoteStateTypes.d.ts.map +1 -1
- package/dist/runtime/bus.d.ts +5 -0
- package/dist/runtime/bus.d.ts.map +1 -1
- package/dist/runtime/bus.js +1 -1
- package/dist/runtime/bus.js.map +1 -1
- package/dist/runtime/capturedAppRuntime.d.ts.map +1 -1
- package/dist/runtime/capturedAppRuntime.js +37 -51
- package/dist/runtime/capturedAppRuntime.js.map +1 -1
- package/dist/runtime/eventBudget.d.ts.map +1 -1
- package/dist/runtime/eventBudget.js +2 -1
- package/dist/runtime/eventBudget.js.map +1 -1
- package/dist/runtime/featureMount.d.ts +34 -2
- package/dist/runtime/featureMount.d.ts.map +1 -1
- package/dist/runtime/featureMount.js +66 -2
- package/dist/runtime/featureMount.js.map +1 -1
- package/dist/runtime/loop.d.ts +12 -0
- package/dist/runtime/loop.d.ts.map +1 -1
- package/dist/runtime/loop.js +8 -5
- package/dist/runtime/loop.js.map +1 -1
- package/dist/state/state.d.ts +2 -2
- package/dist/state/state.d.ts.map +1 -1
- package/dist/state/state.js +2 -2
- package/dist/state/state.js.map +1 -1
- package/dist/state/stateFamily.d.ts +2 -2
- package/dist/state/stateFamily.d.ts.map +1 -1
- package/dist/state/stateFamily.js +80 -32
- package/dist/state/stateFamily.js.map +1 -1
- package/dist/state/stateGroup.d.ts +5 -5
- package/dist/state/stateGroup.d.ts.map +1 -1
- package/dist/state/stateGroup.js +1 -1
- package/dist/state/stateGroup.js.map +1 -1
- package/dist/testkit/flight.testkit.d.ts.map +1 -1
- package/dist/testkit/flight.testkit.js +2 -1
- package/dist/testkit/flight.testkit.js.map +1 -1
- package/dist/wire/tree.d.ts.map +1 -1
- package/dist/wire/tree.js +6 -1
- package/dist/wire/tree.js.map +1 -1
- package/package.json +1 -1
- package/src/calc/asyncCalc-key-attribution.test.ts +182 -0
- package/src/calc/asyncCalc-registry-name.test.ts +82 -0
- package/src/calc/asyncCalc.superseded-commit.test.ts +162 -0
- package/src/calc/asyncCalc.test.ts +35 -7
- package/src/calc/asyncCalc.ts +33 -10
- package/src/calc/asyncCalcTypes.ts +6 -0
- package/src/calc/calcFamily.ts +81 -34
- package/src/calc/family-identity.test.ts +231 -0
- package/src/calc/queryState.ts +36 -5
- package/src/channel/channel-throttle-frames.test.ts +163 -0
- package/src/channel/channel.ts +5 -0
- package/src/compose/compose-identity.test.ts +175 -0
- package/src/compose/composition.ts +8 -4
- package/src/compose/structure.ts +45 -13
- package/src/compose/ui.ts +22 -5
- package/src/event/event.ts +31 -2
- package/src/event/syncTrigger.test.ts +66 -0
- package/src/internal/familyKeys.ts +41 -0
- package/src/internal/platform.test.ts +55 -0
- package/src/internal/platform.ts +72 -0
- package/src/internal/queryDriver-defect-attribution.test.ts +107 -0
- package/src/internal/queryDriver-persist-key.test.ts +118 -0
- package/src/internal/queryDriver.persistence-staleness.test.ts +209 -0
- package/src/internal/queryDriver.ts +92 -80
- package/src/internal/queryDriverConsume.ts +42 -0
- package/src/internal/queryDriverHydrate.ts +9 -3
- package/src/internal/queryDriverPersist.ts +42 -0
- package/src/internal/queryDriverPersistWarn.ts +61 -0
- package/src/internal/queryDriverSettle.ts +85 -0
- package/src/internal/queryDriverStore.ts +9 -74
- package/src/internal/queryDriverTypes.ts +10 -0
- package/src/internal/queryState-and-scheduler-audit.test.ts +170 -0
- package/src/internal/scheduler-multi-source-wake.test.ts +111 -0
- package/src/internal/scheduler.ts +80 -6
- package/src/internal.ts +1 -0
- package/src/reducer/reducer.ts +3 -3
- package/src/remote/pendingQueue.ts +35 -5
- package/src/remote/remoteState.intent-key-stamp.test.ts +207 -0
- package/src/remote/remoteState.key-attribution.test.ts +174 -0
- package/src/remote/remoteState.ts +27 -15
- package/src/remote/remoteStateSend.ts +29 -8
- package/src/remote/remoteStateTypes.ts +7 -0
- package/src/runtime/bus.ts +6 -1
- package/src/runtime/capturedAppRuntime.ts +134 -110
- package/src/runtime/eventBudget.ts +2 -1
- package/src/runtime/feature-mount-ownership.test.ts +539 -0
- package/src/runtime/featureMount.ts +110 -3
- package/src/runtime/loop.ts +29 -6
- package/src/runtime/mount-inflight-ownership.test.ts +361 -0
- package/src/runtime/mount-scope-hardening.test.ts +440 -0
- package/src/runtime/runtime.schema-identity.test.ts +188 -0
- package/src/state/state.ts +8 -4
- package/src/state/stateFamily.ts +86 -36
- package/src/state/stateGroup.ts +24 -6
- package/src/testkit/flight.testkit.ts +2 -1
- package/src/wire/tree-key-presence.test.ts +46 -0
- package/src/wire/tree.ts +9 -1
package/src/state/stateFamily.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { Context, Effect, Layer,
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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: (
|
|
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.
|
|
80
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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.
|
|
226
|
+
? family.validateValueSync(Reflect.apply(initial, undefined, [key]))
|
|
177
227
|
: initial
|
|
178
228
|
return Layer.effect(
|
|
179
229
|
family.store,
|
package/src/state/stateGroup.ts
CHANGED
|
@@ -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
|
-
?
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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) =>
|
|
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
|
-
|
|
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
|
|