@playfast/reform 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/boundary/boundary.test.ts +26 -23
  3. package/src/calc/asyncCalc.invalidate.test.ts +258 -55
  4. package/src/calc/asyncCalc.test.ts +225 -85
  5. package/src/calc/asyncCalc.ts +27 -141
  6. package/src/calc/asyncCalcTypes.ts +147 -0
  7. package/src/calc/calc.test.ts +10 -11
  8. package/src/calc/calcFamily.test.ts +12 -12
  9. package/src/channel/channel.ts +8 -106
  10. package/src/channel/procedures.ts +108 -0
  11. package/src/compose/composition.ts +49 -246
  12. package/src/compose/compositionTypes.ts +249 -0
  13. package/src/event/event.fromSource.test.ts +12 -12
  14. package/src/feature/feature.ts +50 -1210
  15. package/src/feature/feature.typecheck.ts +2 -61
  16. package/src/feature/featureBinding.ts +158 -0
  17. package/src/feature/featureClass.ts +162 -0
  18. package/src/feature/featureConfig.ts +192 -0
  19. package/src/feature/featureFactory.ts +163 -0
  20. package/src/feature/featureModule.ts +136 -0
  21. package/src/feature/featureModule.typecheck.ts +63 -0
  22. package/src/feature/featureNativeTree.ts +152 -0
  23. package/src/feature/featureRequirement.ts +82 -0
  24. package/src/feature/featureVariants.ts +234 -0
  25. package/src/feature/mountFeature.ts +54 -0
  26. package/src/graph/closure.ts +45 -226
  27. package/src/graph/services.ts +95 -0
  28. package/src/graph/summary.ts +134 -0
  29. package/src/internal/queryDriver.ts +20 -89
  30. package/src/internal/queryDriverHydrate.ts +45 -0
  31. package/src/internal/queryDriverTypes.ts +8 -10
  32. package/src/internal/store.test.ts +27 -6
  33. package/src/remote/pendingQueue.ts +145 -0
  34. package/src/remote/remoteState.test.ts +28 -22
  35. package/src/remote/remoteState.ts +63 -489
  36. package/src/remote/remoteStateMake.ts +97 -0
  37. package/src/remote/remoteStateSend.ts +122 -0
  38. package/src/remote/remoteStateTypes.ts +155 -0
  39. package/src/runtime/appRuntime.activation.test.ts +9 -5
  40. package/src/runtime/appRuntime.test.ts +40 -16
  41. package/src/runtime/appRuntime.ts +17 -472
  42. package/src/runtime/capturedAppRuntime.ts +274 -0
  43. package/src/runtime/eventBudget.test.ts +38 -8
  44. package/src/runtime/featureMount.ts +94 -0
  45. package/src/runtime/hardening.test.ts +18 -4
  46. package/src/runtime/instrumentation.test.ts +49 -12
  47. package/src/runtime/loop.test.ts +51 -10
  48. package/src/runtime/runtimeHandle.ts +124 -0
  49. package/src/state/stateFamily.test.ts +25 -8
  50. package/src/testkit/flight.testkit.ts +9 -0
@@ -1,10 +1,36 @@
1
1
  import { expect, it } from '@effect/vitest'
2
- import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
3
- import { AsyncCalc, Engine, Event, State, StateGroup } from '../index'
4
- import { makeFlightGate, tick, until } from '../testkit/flight.testkit'
2
+ import { Data, Effect, Layer, MutableRef, Schema as S } from 'effect'
3
+ import { AsyncCalc, Engine, Event, Reducer, State, StateGroup } from '../index'
4
+ import { makeFlightGate, until } from '../testkit/flight.testkit'
5
5
 
6
6
  class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
7
7
 
8
+ interface Notifier {
9
+ readonly subscribe: (listener: () => void) => () => void
10
+ }
11
+
12
+ // The notification scheduler drains every listener of a write in one pass, and a
13
+ // driver subscribes before the test does: our own notification proves its
14
+ // trigger already ran, so an inert change can be asserted without waiting.
15
+ const afterNotify = <A, E, R>(
16
+ source: Notifier,
17
+ write: Effect.Effect<A, E, R>,
18
+ ): Effect.Effect<void, E, R> =>
19
+ Effect.suspend(() => {
20
+ const fired = MutableRef.make(false)
21
+ const off = source.subscribe(() => MutableRef.set(fired, true))
22
+ return write.pipe(
23
+ Effect.zipRight(
24
+ until(
25
+ () => MutableRef.get(fired),
26
+ (value) => value,
27
+ ),
28
+ ),
29
+ Effect.ensuring(Effect.sync(off)),
30
+ Effect.asVoid,
31
+ )
32
+ })
33
+
8
34
  it.live('runs the query and resolves Loading -> Success', () => {
9
35
  class Count extends State.make('count', S.Number) {}
10
36
  class Inputs extends StateGroup.make(Count) {}
@@ -13,16 +39,24 @@ it.live('runs the query and resolves Loading -> Success', () => {
13
39
  output: S.Number,
14
40
  alwaysOn: true,
15
41
  }) {}
42
+ const gate = makeFlightGate()
43
+ // Armed before the layer builds, so the very first flight parks at the gate.
44
+ Effect.runSync(gate.hold)
16
45
  const DoubledLive = AsyncCalc.live(Doubled, {
17
- query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(20))),
46
+ query: ({ count }) => gate.through(Effect.succeed(count * 2)),
18
47
  })
19
48
  const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
20
49
 
21
50
  return Effect.gen(function* () {
22
51
  const store = yield* Doubled.store
52
+ yield* gate.awaitEntry(1)
23
53
  expect(store.get()._tag).toBe('Loading')
24
- yield* tick(40)
25
- const value = store.get()
54
+
55
+ yield* gate.release
56
+ const value = yield* until(
57
+ () => store.get(),
58
+ (state) => state._tag === 'Success',
59
+ )
26
60
  expect(value._tag).toBe('Success')
27
61
  if (value._tag === 'Success') {
28
62
  expect(value.value).toBe(10)
@@ -47,8 +81,10 @@ it.live('a failing query resolves to Error carrying the failure', () => {
47
81
 
48
82
  return Effect.gen(function* () {
49
83
  const store = yield* Q.store
50
- yield* tick()
51
- const value = store.get()
84
+ const value = yield* until(
85
+ () => store.get(),
86
+ (state) => state._tag === 'Error',
87
+ )
52
88
  expect(value._tag).toBe('Error')
53
89
  if (value._tag === 'Error') {
54
90
  expect(value.error.message).toBe('nope')
@@ -65,23 +101,33 @@ it.live('a dependency change re-fetches, keeping the last value with refetching:
65
101
  output: S.Number,
66
102
  alwaysOn: true,
67
103
  }) {}
104
+ const gate = makeFlightGate()
68
105
  const DoubledLive = AsyncCalc.live(Doubled, {
69
- query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(20))),
106
+ query: ({ count }) => gate.through(Effect.succeed(count * 2)),
70
107
  })
71
108
  const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
72
109
 
73
110
  return Effect.gen(function* () {
74
111
  const source = yield* StateGroup.select(Inputs, 'count').store
75
112
  const store = yield* Doubled.store
76
- yield* tick(40)
113
+ yield* until(
114
+ () => store.get(),
115
+ (v) => v._tag === 'Success',
116
+ )
77
117
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
78
118
 
119
+ // Hold the re-fetch at the gate: the previous value stays visible under it.
120
+ yield* gate.hold
79
121
  source.set(7)
80
- yield* tick(5)
122
+ yield* gate.awaitEntry(2)
81
123
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: true })
82
124
 
83
- yield* tick(40)
84
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 14, refetching: false })
125
+ yield* gate.release
126
+ const settled = yield* until(
127
+ () => store.get(),
128
+ (v) => v._tag === 'Success' && v.refetching === false,
129
+ )
130
+ expect(settled).toMatchObject({ _tag: 'Success', value: 14, refetching: false })
85
131
  }).pipe(Effect.provide(TestLayer))
86
132
  })
87
133
 
@@ -94,24 +140,35 @@ it.live('latest-wins: a rapid second change cancels the in-flight run', () => {
94
140
  output: S.Number,
95
141
  alwaysOn: true,
96
142
  }) {}
143
+ const gate = makeFlightGate()
97
144
  const DoubledLive = AsyncCalc.live(Doubled, {
98
145
  query: ({ count }) =>
99
- Effect.sync(() => {
100
- runs.n += 1
101
- return count * 2
102
- }).pipe(Effect.delay(Duration.millis(25))),
146
+ gate.through(
147
+ Effect.sync(() => {
148
+ runs.n += 1
149
+ return count * 2
150
+ }),
151
+ ),
103
152
  })
104
153
  const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
105
154
 
106
155
  return Effect.gen(function* () {
107
156
  const source = yield* StateGroup.select(Inputs, 'count').store
108
157
  const store = yield* Doubled.store
109
- yield* tick(40)
158
+ yield* until(
159
+ () => store.get(),
160
+ (v) => v._tag === 'Success',
161
+ )
110
162
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
111
163
 
164
+ // `4` lands while `3` is still parked at the gate, so it can only win by
165
+ // cancelling it.
166
+ yield* gate.hold
112
167
  source.set(3)
113
- yield* tick(5)
168
+ yield* gate.awaitEntry(2)
114
169
  source.set(4)
170
+ yield* gate.awaitEntry(3)
171
+ yield* gate.release
115
172
  const settled = yield* until(
116
173
  () => store.get(),
117
174
  (v) => v._tag === 'Success' && v.refetching === false && v.value !== 2,
@@ -127,8 +184,9 @@ it.live('a gated query starts Idle while disabled and runs once enabled', () =>
127
184
  inputs: [StateGroup.select(Inputs, 'count')],
128
185
  output: S.Number,
129
186
  }) {}
187
+ const gate = makeFlightGate()
130
188
  const DoubledLive = AsyncCalc.live(Doubled, {
131
- query: ({ count }) => Effect.succeed(count * 2),
189
+ query: ({ count }) => gate.through(Effect.succeed(count * 2)),
132
190
  disabled: ({ count }) => count < 0,
133
191
  })
134
192
  const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })))
@@ -136,12 +194,16 @@ it.live('a gated query starts Idle while disabled and runs once enabled', () =>
136
194
  return Effect.gen(function* () {
137
195
  const source = yield* StateGroup.select(Inputs, 'count').store
138
196
  const store = yield* Doubled.store
139
- yield* tick()
140
197
  expect(store.get()._tag).toBe('Idle')
141
198
 
142
199
  source.set(5)
143
- yield* tick()
144
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
200
+ const settled = yield* until(
201
+ () => store.get(),
202
+ (v) => v._tag === 'Success',
203
+ )
204
+ expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
205
+ // Exactly one flight ever ran: the disabled window produced none.
206
+ expect(gate.entered()).toBe(1)
145
207
  }).pipe(Effect.provide(TestLayer))
146
208
  })
147
209
 
@@ -155,12 +217,15 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
155
217
  output: S.Number,
156
218
  alwaysOn: true,
157
219
  }) {}
220
+ const gate = makeFlightGate()
158
221
  const QLive = AsyncCalc.live(Q, {
159
222
  query: ({ page }) =>
160
- Effect.sync(() => {
161
- runs.n += 1
162
- return page
163
- }),
223
+ gate.through(
224
+ Effect.sync(() => {
225
+ runs.n += 1
226
+ return page
227
+ }),
228
+ ),
164
229
  invalidateBy: ({ page }) => [page],
165
230
  })
166
231
  const TestLayer = QLive.pipe(
@@ -171,17 +236,27 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
171
236
  const status = yield* StateGroup.select(Inputs, 'status').store
172
237
  const page = yield* StateGroup.select(Inputs, 'page').store
173
238
  const store = yield* Q.store
174
- yield* tick()
239
+ yield* until(
240
+ () => store.get(),
241
+ (v) => v._tag === 'Success',
242
+ )
175
243
  expect(runs.n).toBe(1)
176
244
 
177
- status.set('b')
178
- yield* tick()
245
+ // Holding the gate means any flight the status churn triggered would still
246
+ // be marked fetching here, and could not have settled back out of view.
247
+ yield* gate.hold
248
+ yield* afterNotify(status, Effect.sync(() => status.set('b')))
249
+ expect(store.get()).toMatchObject({ _tag: 'Success', refetching: false })
179
250
  expect(runs.n).toBe(1)
251
+ yield* gate.release
180
252
 
181
253
  page.set(2)
182
- yield* tick()
254
+ const settled = yield* until(
255
+ () => store.get(),
256
+ (v) => v._tag === 'Success' && v.refetching === false && v.value === 2,
257
+ )
183
258
  expect(runs.n).toBe(2)
184
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
259
+ expect(settled).toMatchObject({ _tag: 'Success', value: 2 })
185
260
  }).pipe(Effect.provide(TestLayer))
186
261
  })
187
262
 
@@ -258,14 +333,13 @@ it.live(
258
333
  )
259
334
  expect(completed).toEqual([1])
260
335
 
261
- // Hold the `2` flight at the gate so 3 and 4 land while it is in flight.
336
+ // Hold the `2` flight at the gate so 3 and 4 land, in that order, while it
337
+ // is in flight.
262
338
  yield* gate.hold
263
339
  source.set(2)
264
340
  yield* gate.awaitEntry(2)
265
- source.set(3)
266
- yield* tick(5)
267
- source.set(4)
268
- yield* tick(5)
341
+ yield* afterNotify(source, Effect.sync(() => source.set(3)))
342
+ yield* afterNotify(source, Effect.sync(() => source.set(4)))
269
343
  yield* gate.release
270
344
  const settled = yield* until(
271
345
  () => store.get(),
@@ -287,8 +361,15 @@ it.live(
287
361
  output: S.Number,
288
362
  alwaysOn: true,
289
363
  }) {}
364
+ // Two gates: one parks the flight the burst interrupts, the other parks the
365
+ // trailing run, so the stale settle in between is a stable state, not a race.
366
+ const firstGate = makeFlightGate()
367
+ const trailingGate = makeFlightGate()
290
368
  const DoubledLive = AsyncCalc.live(Doubled, {
291
- query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(30))),
369
+ query: ({ count }) =>
370
+ count === 3
371
+ ? trailingGate.through(Effect.succeed(count * 2))
372
+ : firstGate.through(Effect.succeed(count * 2)),
292
373
  coalesce: 'trailing',
293
374
  })
294
375
  const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
@@ -301,15 +382,17 @@ it.live(
301
382
  (v) => v._tag === 'Success',
302
383
  )
303
384
 
385
+ yield* firstGate.hold
386
+ yield* trailingGate.hold
304
387
  source.set(2)
305
- yield* tick(5)
306
- source.set(3)
307
- const midflight = yield* until(
308
- () => store.get(),
309
- (v) => v._tag === 'Success' && v.value === 4,
310
- )
311
- expect(midflight).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
388
+ yield* firstGate.awaitEntry(2)
389
+ yield* afterNotify(source, Effect.sync(() => source.set(3)))
312
390
 
391
+ yield* firstGate.release
392
+ yield* trailingGate.awaitEntry(1)
393
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
394
+
395
+ yield* trailingGate.release
313
396
  const settled = yield* until(
314
397
  () => store.get(),
315
398
  (v) => v._tag === 'Success' && v.refetching === false,
@@ -354,17 +437,14 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
354
437
  yield* gate.hold
355
438
  source.set(2)
356
439
  yield* gate.awaitEntry(2)
357
- source.set(3)
358
- yield* tick(5)
359
- source.set(-1)
360
- yield* tick(5)
440
+ yield* afterNotify(source, Effect.sync(() => source.set(3)))
441
+ yield* afterNotify(source, Effect.sync(() => source.set(-1)))
361
442
  yield* gate.release
362
- // Asserting an absence: give the consumer a real window to wake and (wrongly)
363
- // run the queued key. A slow machine only widens the window, never narrows it.
364
- yield* tick(60)
365
443
  expect(store.get()._tag).toBe('Idle')
366
444
  expect(completed).toEqual([1, 2])
367
445
 
446
+ // The queue is FIFO: once 5 has landed, 3's fate is settled, and it must not
447
+ // appear between them.
368
448
  source.set(5)
369
449
  const settled = yield* until(
370
450
  () => store.get(),
@@ -385,12 +465,15 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
385
465
  output: S.Number,
386
466
  alwaysOn: true,
387
467
  }) {}
468
+ const gate = makeFlightGate()
388
469
  const QLive = AsyncCalc.live(Q, {
389
470
  query: ({ count }) =>
390
- Effect.sync(() => {
391
- runs.n += 1
392
- return count * 2
393
- }).pipe(Effect.delay(Duration.millis(15))),
471
+ gate.through(
472
+ Effect.sync(() => {
473
+ runs.n += 1
474
+ return count * 2
475
+ }),
476
+ ),
394
477
  invalidateOn: [Poke],
395
478
  })
396
479
  const TestLayer = QLive.pipe(
@@ -400,15 +483,24 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
400
483
 
401
484
  return Effect.gen(function* () {
402
485
  const store = yield* Q.store
403
- yield* tick(30)
486
+ yield* until(
487
+ () => store.get(),
488
+ (v) => v._tag === 'Success' && v.refetching === false,
489
+ )
404
490
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
405
491
  expect(runs.n).toBe(1)
406
492
 
493
+ yield* gate.hold
407
494
  yield* Event.dispatch(Poke, {})
408
- yield* tick(5)
495
+ yield* gate.awaitEntry(2)
409
496
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
410
- yield* tick(30)
411
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
497
+
498
+ yield* gate.release
499
+ const settled = yield* until(
500
+ () => store.get(),
501
+ (v) => v._tag === 'Success' && v.refetching === false,
502
+ )
503
+ expect(settled).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
412
504
  expect(runs.n).toBe(2)
413
505
  }).pipe(Effect.provide(TestLayer))
414
506
  })
@@ -426,12 +518,15 @@ it.live(
426
518
  output: S.Number,
427
519
  alwaysOn: true,
428
520
  }) {}
521
+ const gate = makeFlightGate()
429
522
  const QLive = AsyncCalc.live(Q, {
430
523
  query: ({ page }) =>
431
- Effect.sync(() => {
432
- runs.n += 1
433
- return page
434
- }),
524
+ gate.through(
525
+ Effect.sync(() => {
526
+ runs.n += 1
527
+ return page
528
+ }),
529
+ ),
435
530
  invalidateBy: ({ page }) => [page],
436
531
  invalidateOn: [Poke],
437
532
  })
@@ -442,15 +537,21 @@ it.live(
442
537
 
443
538
  return Effect.gen(function* () {
444
539
  const status = yield* StateGroup.select(Inputs, 'status').store
445
- yield* tick()
540
+ const store = yield* Q.store
541
+ yield* until(
542
+ () => store.get(),
543
+ (v) => v._tag === 'Success' && v.refetching === false,
544
+ )
446
545
  expect(runs.n).toBe(1)
447
546
 
448
- status.set('b')
449
- yield* tick()
547
+ yield* gate.hold
548
+ yield* afterNotify(status, Effect.sync(() => status.set('b')))
549
+ expect(store.get()).toMatchObject({ _tag: 'Success', refetching: false })
450
550
  expect(runs.n).toBe(1)
551
+ yield* gate.release
451
552
 
452
553
  yield* Event.dispatch(Poke, {})
453
- yield* tick()
554
+ yield* gate.awaitEntry(2)
454
555
  expect(runs.n).toBe(2)
455
556
  }).pipe(Effect.provide(TestLayer))
456
557
  },
@@ -462,6 +563,10 @@ it.live(
462
563
  class Count extends State.make('count', S.Number) {}
463
564
  class Inputs extends StateGroup.make(Count) {}
464
565
  class Poke extends Event.make('Poke', S.Struct({})) {}
566
+ // A second reducer on the same event: reducers of one frame apply in a single
567
+ // pass, so this state landing proves the query's revision write landed too.
568
+ class Pokes extends State.make('pokes', S.Number) {}
569
+ const PokesReducer = Reducer.make('pokes', { states: [Pokes], events: [Poke] })
465
570
  const runs = { n: 0 }
466
571
  class Q extends AsyncCalc.make('Q', {
467
572
  inputs: [StateGroup.select(Inputs, 'count')],
@@ -476,25 +581,33 @@ it.live(
476
581
  disabled: ({ count }) => count < 0,
477
582
  invalidateOn: [Poke],
478
583
  })
479
- const TestLayer = QLive.pipe(
584
+ const TestLayer = Layer.mergeAll(
585
+ QLive,
586
+ Reducer.live(PokesReducer, (seen) => seen + 1),
587
+ ).pipe(
480
588
  Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })),
589
+ Layer.provideMerge(State.live(Pokes, 0)),
481
590
  Layer.provideMerge(Engine),
482
591
  )
483
592
 
484
593
  return Effect.gen(function* () {
485
594
  const source = yield* StateGroup.select(Inputs, 'count').store
595
+ const pokes = yield* Pokes.store
486
596
  const store = yield* Q.store
487
- yield* tick()
488
597
  expect(store.get()._tag).toBe('Idle')
489
598
 
490
- yield* Event.dispatch(Poke, {})
491
- yield* tick()
599
+ // An enqueued run marks the state fetching before it can run, so staying
600
+ // Idle across the frame is proof that nothing was enqueued.
601
+ yield* afterNotify(pokes, Event.dispatch(Poke, {}))
492
602
  expect(store.get()._tag).toBe('Idle')
493
603
  expect(runs.n).toBe(0)
494
604
 
495
605
  source.set(5)
496
- yield* tick()
497
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
606
+ const settled = yield* until(
607
+ () => store.get(),
608
+ (v) => v._tag === 'Success',
609
+ )
610
+ expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
498
611
  expect(runs.n).toBe(1)
499
612
  }).pipe(Effect.provide(TestLayer))
500
613
  },
@@ -516,35 +629,62 @@ it.live('invalidateOn: two async calcs keep independent hidden revisions', () =>
516
629
  output: S.Number,
517
630
  alwaysOn: true,
518
631
  }) {}
632
+ const gateA = makeFlightGate()
633
+ const gateB = makeFlightGate()
519
634
  const TestLayer = Layer.mergeAll(
520
635
  AsyncCalc.live(QA, {
521
636
  query: ({ count }) =>
522
- Effect.sync(() => {
523
- runs.a += 1
524
- return count
525
- }),
637
+ gateA.through(
638
+ Effect.sync(() => {
639
+ runs.a += 1
640
+ return count
641
+ }),
642
+ ),
526
643
  invalidateOn: [PokeA],
527
644
  }),
528
645
  AsyncCalc.live(QB, {
529
646
  query: ({ count }) =>
530
- Effect.sync(() => {
531
- runs.b += 1
532
- return count
533
- }),
647
+ gateB.through(
648
+ Effect.sync(() => {
649
+ runs.b += 1
650
+ return count
651
+ }),
652
+ ),
534
653
  invalidateOn: [PokeB],
535
654
  }),
536
655
  ).pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })), Layer.provideMerge(Engine))
537
656
 
538
657
  return Effect.gen(function* () {
539
- yield* tick()
658
+ const storeA = yield* QA.store
659
+ const storeB = yield* QB.store
660
+ yield* until(
661
+ () => storeA.get()._tag === 'Success' && storeB.get()._tag === 'Success',
662
+ (both) => both,
663
+ )
540
664
  expect(runs).toEqual({ a: 1, b: 1 })
541
665
 
666
+ // B's gate is held, so a revision leak from PokeA would leave B visibly
667
+ // fetching instead of settling back out of sight.
668
+ yield* gateB.hold
542
669
  yield* Event.dispatch(PokeA, {})
543
- yield* tick()
670
+ yield* gateA.awaitEntry(2)
671
+ yield* until(
672
+ () => storeA.get(),
673
+ (v) => v._tag === 'Success' && v.refetching === false,
674
+ )
675
+ expect(storeB.get()).toMatchObject({ _tag: 'Success', refetching: false })
544
676
  expect(runs).toEqual({ a: 2, b: 1 })
677
+ yield* gateB.release
545
678
 
679
+ yield* gateA.hold
546
680
  yield* Event.dispatch(PokeB, {})
547
- yield* tick()
681
+ yield* gateB.awaitEntry(2)
682
+ yield* until(
683
+ () => storeB.get(),
684
+ (v) => v._tag === 'Success' && v.refetching === false,
685
+ )
686
+ expect(storeA.get()).toMatchObject({ _tag: 'Success', refetching: false })
548
687
  expect(runs).toEqual({ a: 2, b: 2 })
688
+ yield* gateA.release
549
689
  }).pipe(Effect.provide(TestLayer))
550
690
  })