@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.
- 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 +1 -1
- package/dist/event/event.d.ts.map +1 -1
- package/dist/event/event.js +26 -4
- 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 +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -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/ui/trigger.d.ts +3 -0
- package/dist/ui/trigger.d.ts.map +1 -1
- package/dist/ui/trigger.js +14 -1
- package/dist/ui/trigger.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 +34 -5
- 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 +4 -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/ui/trigger.ts +18 -0
- package/src/wire/tree-key-presence.test.ts +46 -0
- 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
|
+
})
|
package/src/channel/channel.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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,
|
package/src/compose/structure.ts
CHANGED
|
@@ -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 (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
const
|
|
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
|
-
//
|
|
217
|
-
|
|
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
|
})
|
package/src/event/event.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
})
|