@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
5
5
  "keywords": [
6
6
  "effect",
@@ -1,16 +1,7 @@
1
1
  import { expect, it } from '@effect/vitest'
2
- import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
2
+ import { Data, Effect, Layer, Schema as S } from 'effect'
3
3
  import { AsyncCalc, Boundary, Calc, Engine, Event, State, StateGroup } from '../index'
4
-
5
- const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
6
-
7
- const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
8
- Effect.suspend(() => {
9
- const a = read()
10
- return pred(a) || rounds <= 0
11
- ? Effect.succeed(a)
12
- : Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
13
- })
4
+ import { makeFlightGate, until } from '../testkit/flight.testkit'
14
5
 
15
6
  class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
16
7
 
@@ -28,20 +19,20 @@ it.live('pending while ANY covered query is on its first load; one Ready when al
28
19
  alwaysOn: true,
29
20
  }) {}
30
21
  class Gate extends Boundary.make('Gate', { over: [Fast, Slow] }) {}
22
+ // Held before the layer builds so Slow is parked in flight from its very first run.
23
+ const slowGate = makeFlightGate()
24
+ Effect.runSync(slowGate.hold)
31
25
  const TestLayer = Boundary.live(Gate).pipe(
32
26
  Layer.provideMerge(
33
27
  Layer.merge(
34
- AsyncCalc.live(Fast, {
35
- query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('10 millis')),
36
- }),
37
- AsyncCalc.live(Slow, {
38
- query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('40 millis')),
39
- }),
28
+ AsyncCalc.live(Fast, { query: ({ seed }) => Effect.succeed(seed) }),
29
+ AsyncCalc.live(Slow, { query: ({ seed }) => slowGate.through(Effect.succeed(seed)) }),
40
30
  ),
41
31
  ),
42
32
  Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
43
33
  )
44
34
  return Effect.gen(function* () {
35
+ const fastStore = yield* Fast.store
45
36
  const store = yield* Gate.store
46
37
  const observed: Array<string> = []
47
38
  store.subscribe(() => {
@@ -49,9 +40,14 @@ it.live('pending while ANY covered query is on its first load; one Ready when al
49
40
  })
50
41
  expect(store.get()._tag).toBe('Pending')
51
42
 
52
- yield* tick(20)
43
+ yield* until(
44
+ () => fastStore.get()._tag,
45
+ (t) => t === 'Success',
46
+ )
47
+ yield* slowGate.awaitEntry(1)
53
48
  expect(store.get()._tag).toBe('Pending')
54
49
 
50
+ yield* slowGate.release
55
51
  yield* until(
56
52
  () => store.get()._tag,
57
53
  (t) => t === 'Ready',
@@ -190,16 +186,17 @@ it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
190
186
  output: S.Number,
191
187
  }) {}
192
188
  class Gate extends Boundary.make('Gate', { over: [A, B] }) {}
189
+ const gateA = makeFlightGate()
190
+ const gateB = makeFlightGate()
191
+ Effect.runSync(Effect.zipRight(gateA.hold, gateB.hold))
193
192
  const TestLayer = Boundary.live(Gate).pipe(
194
193
  Layer.provideMerge(
195
194
  AsyncCalc.live(B, {
196
- query: () => Effect.succeed(2).pipe(Effect.delay('15 millis')),
195
+ query: () => gateB.through(Effect.succeed(2)),
197
196
  disabled: ({ A: a }) => a._tag !== 'Success',
198
197
  }),
199
198
  ),
200
- Layer.provideMerge(
201
- AsyncCalc.live(A, { query: () => Effect.succeed(1).pipe(Effect.delay('15 millis')) }),
202
- ),
199
+ Layer.provideMerge(AsyncCalc.live(A, { query: () => gateA.through(Effect.succeed(1)) })),
203
200
  Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
204
201
  )
205
202
  return Effect.gen(function* () {
@@ -210,11 +207,17 @@ it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
210
207
  })
211
208
  expect(store.get()._tag).toBe('Pending')
212
209
 
210
+ yield* gateA.awaitEntry(1)
211
+ yield* gateA.release
212
+ // B entering the gate proves A settled and the next hop opened — no Ready may exist in that seam.
213
+ yield* gateB.awaitEntry(1)
214
+ expect(store.get()._tag).toBe('Pending')
215
+
216
+ yield* gateB.release
213
217
  yield* until(
214
218
  () => store.get()._tag,
215
219
  (t) => t === 'Ready',
216
220
  )
217
- yield* tick(30)
218
221
  expect(observed).toEqual(['Ready'])
219
222
  }).pipe(Effect.provide(TestLayer))
220
223
  })
@@ -1,10 +1,30 @@
1
1
  import { expect, it } from '@effect/vitest'
2
- import { Duration, Effect, Layer, ManagedRuntime, MutableRef, Option, Schema as S } from 'effect'
2
+ import { Effect, Layer, ManagedRuntime, MutableRef, Option, Schema as S } from 'effect'
3
3
  import { AsyncCalc, Engine, forceSync, RemoteState, State, StateGroup } from '../index'
4
4
  import { QueryStore } from '../internal/queryStore'
5
5
  import { Queries } from '../runtime/queries'
6
+ import { makeFlightGate, until } from '../testkit/flight.testkit'
6
7
 
7
- const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
8
+ // Yielding through a runtime resumes at the tail of its scheduler queue, so work
9
+ // already scheduled there — a hydration continuation, a consumer wake — has run.
10
+ const scheduled: Effect.Effect<void> = Effect.yieldNow()
11
+
12
+ interface Notifier {
13
+ readonly subscribe: (listener: () => void) => () => void
14
+ }
15
+
16
+ // One write notifies every listener in a single scheduler drain, and the driver
17
+ // subscribed before the test did: our own notification proves its trigger ran.
18
+ const afterNotify = (source: Notifier, write: () => void): Effect.Effect<void> =>
19
+ Effect.suspend(() => {
20
+ const fired = MutableRef.make(false)
21
+ const off = source.subscribe(() => MutableRef.set(fired, true))
22
+ write()
23
+ return until(
24
+ () => MutableRef.get(fired),
25
+ (value) => value,
26
+ ).pipe(Effect.ensuring(Effect.sync(off)), Effect.asVoid)
27
+ })
8
28
 
9
29
  const isStale = (name: string) =>
10
30
  Effect.map(Queries, (q) => q.byName.get(name)?.snapshot().isStale ?? false)
@@ -73,16 +93,24 @@ it.live('invalidate marks stale without fetching; a fresh subscriber then refetc
73
93
 
74
94
  return Effect.gen(function* () {
75
95
  const store = yield* Q.store
76
- yield* tick()
96
+ yield* until(
97
+ () => store.get(),
98
+ (v) => v._tag === 'Success' && v.refetching === false,
99
+ )
77
100
  expect(runs.n).toBe(1)
78
101
 
102
+ // A refetch would mark the state fetching inside `invalidate` itself, so the
103
+ // reader-less mark is observable without waiting for anything.
79
104
  yield* AsyncCalc.invalidate(Q)
80
- yield* tick()
81
105
  expect(yield* isStale('Q')).toBe(true)
106
+ expect(store.get()).toMatchObject({ _tag: 'Success', refetching: false })
82
107
  expect(runs.n).toBe(1)
83
108
 
84
109
  store.subscribe(() => {})
85
- yield* tick()
110
+ yield* until(
111
+ () => store.get(),
112
+ (v) => v._tag === 'Success' && v.refetching === false && runs.n > 1,
113
+ )
86
114
  expect(runs.n).toBe(2)
87
115
  expect(yield* isStale('Q')).toBe(false)
88
116
  }).pipe(Effect.provide(TestLayer))
@@ -97,12 +125,15 @@ it.live('invalidate with an active reader auto-refetches immediately, keeping th
97
125
  output: S.Number,
98
126
  alwaysOn: true,
99
127
  }) {}
128
+ const gate = makeFlightGate()
100
129
  const QLive = AsyncCalc.live(Q, {
101
130
  query: ({ count }) =>
102
- Effect.sync(() => {
103
- runs.n += 1
104
- return count
105
- }).pipe(Effect.delay(Duration.millis(20))),
131
+ gate.through(
132
+ Effect.sync(() => {
133
+ runs.n += 1
134
+ return count
135
+ }),
136
+ ),
106
137
  })
107
138
  const TestLayer = QLive.pipe(
108
139
  Layer.provideMerge(StateGroup.live(Inputs, { count: 7 })),
@@ -112,15 +143,26 @@ it.live('invalidate with an active reader auto-refetches immediately, keeping th
112
143
  return Effect.gen(function* () {
113
144
  const store = yield* Q.store
114
145
  store.subscribe(() => {})
115
- yield* tick(40)
146
+ yield* until(
147
+ () => store.get(),
148
+ (v) => v._tag === 'Success' && v.refetching === false,
149
+ )
116
150
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
117
151
  expect(runs.n).toBe(1)
118
152
 
153
+ // The auto-refetch is parked at the gate, so "kept the value while
154
+ // refetching" is a stable state rather than a window to catch.
155
+ yield* gate.hold
119
156
  yield* AsyncCalc.invalidate(Q)
120
- yield* tick(5)
157
+ yield* gate.awaitEntry(2)
121
158
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
122
- yield* tick(40)
123
- expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
159
+
160
+ yield* gate.release
161
+ const settled = yield* until(
162
+ () => store.get(),
163
+ (v) => v._tag === 'Success' && v.refetching === false,
164
+ )
165
+ expect(settled).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
124
166
  expect(runs.n).toBe(2)
125
167
  }).pipe(Effect.provide(TestLayer))
126
168
  })
@@ -147,12 +189,19 @@ it.live('refetch forces a run regardless of staleness', () => {
147
189
  )
148
190
 
149
191
  return Effect.gen(function* () {
150
- yield* tick()
192
+ const store = yield* Q.store
193
+ yield* until(
194
+ () => store.get(),
195
+ (v) => v._tag === 'Success' && v.refetching === false,
196
+ )
151
197
  expect(runs.n).toBe(1)
152
198
  expect(yield* isStale('Q')).toBe(false)
153
199
 
154
200
  yield* AsyncCalc.refetch(Q)
155
- yield* tick()
201
+ yield* until(
202
+ () => runs.n,
203
+ (n) => n > 1,
204
+ )
156
205
  expect(runs.n).toBe(2)
157
206
  }).pipe(Effect.provide(TestLayer))
158
207
  })
@@ -168,8 +217,12 @@ it.live(
168
217
  output: S.Number,
169
218
  alwaysOn: true,
170
219
  }) {}
220
+ const gate = makeFlightGate()
221
+ // Armed before the layer builds: the revalidation parks, so the hydrated
222
+ // (stale) paint is a stable state rather than a window to catch.
223
+ Effect.runSync(gate.hold)
171
224
  const QLive = AsyncCalc.live(Q, {
172
- query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(25))),
225
+ query: ({ count }) => gate.through(Effect.succeed(count * 2)),
173
226
  persist: { key: 'P' },
174
227
  })
175
228
  const TestLayer = QLive.pipe(
@@ -180,10 +233,19 @@ it.live(
180
233
 
181
234
  return Effect.gen(function* () {
182
235
  const view = yield* Q.store
236
+ yield* gate.awaitEntry(1)
183
237
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
184
238
 
185
- yield* tick(45)
186
- expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
239
+ yield* gate.release
240
+ const settled = yield* until(
241
+ () => view.get(),
242
+ (v) => v._tag === 'Success' && v.refetching === false,
243
+ )
244
+ expect(settled).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
245
+ yield* until(
246
+ () => store.map.get('P'),
247
+ (persisted) => persisted === 10,
248
+ )
187
249
  expect(store.map.get('P')).toBe(10)
188
250
  }).pipe(Effect.provide(TestLayer))
189
251
  },
@@ -211,15 +273,32 @@ it('persist: a re-armed watcher does not duplicate the generation covering hydra
211
273
 
212
274
  const view = forceSync(() => runtime.runSync(Q.store))
213
275
  const stop = rearmOnEveryChange(view.subscribe)
214
- await runtime.runPromise(tick())
276
+ await runtime.runPromise(
277
+ until(
278
+ () => runs.n,
279
+ (n) => n > 0,
280
+ ),
281
+ )
215
282
  expect(runs.n).toBe(1)
216
283
 
217
284
  cached.resolve(Option.some(99))
218
- await runtime.runPromise(tick())
285
+ await runtime.runPromise(
286
+ until(
287
+ () => view.get(),
288
+ (v) => v._tag === 'Success',
289
+ ),
290
+ )
219
291
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
220
292
 
293
+ // A duplicated generation would leave a trailing request pending, so settling
294
+ // out of `refetching` is what proves the run was covered exactly once.
221
295
  fresh.resolve(10)
222
- await runtime.runPromise(tick())
296
+ await runtime.runPromise(
297
+ until(
298
+ () => view.get(),
299
+ (v) => v._tag === 'Success' && v.refetching === false,
300
+ ),
301
+ )
223
302
  expect(runs.n).toBe(1)
224
303
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
225
304
  stop()
@@ -248,13 +327,28 @@ it('persist: an explicit refetch still trails the generation covering hydration'
248
327
 
249
328
  const view = forceSync(() => runtime.runSync(CoveredHydrationRefetchQ.store))
250
329
  const stop = rearmOnEveryChange(view.subscribe)
251
- await runtime.runPromise(tick())
330
+ await runtime.runPromise(
331
+ until(
332
+ () => runs.n,
333
+ (n) => n > 0,
334
+ ),
335
+ )
252
336
  cached.resolve(Option.some(99))
253
- await runtime.runPromise(tick())
337
+ await runtime.runPromise(
338
+ until(
339
+ () => view.get(),
340
+ (v) => v._tag === 'Success',
341
+ ),
342
+ )
254
343
 
255
344
  await runtime.runPromise(AsyncCalc.refetch(CoveredHydrationRefetchQ))
256
345
  fresh.resolve(10)
257
- await runtime.runPromise(tick())
346
+ await runtime.runPromise(
347
+ until(
348
+ () => view.get(),
349
+ (v) => v._tag === 'Success' && v.refetching === false,
350
+ ),
351
+ )
258
352
  expect(runs.n).toBe(2)
259
353
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
260
354
  stop()
@@ -282,18 +376,33 @@ it('invalidate after a requested generation stays uncovered when hydration lands
282
376
  const runtime = ManagedRuntime.make(TestLayer)
283
377
 
284
378
  const view = forceSync(() => runtime.runSync(InFlightInvalidateQ.store))
285
- await runtime.runPromise(tick())
379
+ await runtime.runPromise(
380
+ until(
381
+ () => runs.n,
382
+ (n) => n > 0,
383
+ ),
384
+ )
286
385
  expect(runs.n).toBe(1)
287
386
 
288
387
  await runtime.runPromise(AsyncCalc.invalidate(InFlightInvalidateQ))
289
388
  expect(await runtime.runPromise(isStale('InFlightInvalidateQ'))).toBe(true)
290
389
  cached.resolve(Option.some(99))
291
- await runtime.runPromise(tick())
390
+ await runtime.runPromise(
391
+ until(
392
+ () => view.get(),
393
+ (v) => v._tag === 'Success',
394
+ ),
395
+ )
292
396
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
293
397
 
294
398
  const stop = rearmOnEveryChange(view.subscribe)
295
399
  fresh.resolve(7)
296
- await runtime.runPromise(tick())
400
+ await runtime.runPromise(
401
+ until(
402
+ () => view.get(),
403
+ (v) => v._tag === 'Success' && v.refetching === false,
404
+ ),
405
+ )
297
406
  expect(runs.n).toBe(2)
298
407
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
299
408
  stop()
@@ -319,14 +428,24 @@ it('a failed refetch leaves its stale mark uncovered, so a fresh subscriber retr
319
428
  )
320
429
 
321
430
  const view = forceSync(() => runtime.runSync(FailedCoverQ.store))
322
- await runtime.runPromise(tick())
431
+ await runtime.runPromise(
432
+ until(
433
+ () => view.get(),
434
+ (v) => v._tag === 'Success',
435
+ ),
436
+ )
323
437
  expect(runs.n).toBe(1)
324
438
 
325
439
  // Invalidate with no readers, then attach one: that reader's refetch covers the
326
440
  // stale mark, and it fails.
327
441
  await runtime.runPromise(AsyncCalc.invalidate(FailedCoverQ))
328
442
  const first = view.subscribe(() => {})
329
- await runtime.runPromise(tick())
443
+ await runtime.runPromise(
444
+ until(
445
+ () => view.get(),
446
+ (v) => v._tag === 'Error',
447
+ ),
448
+ )
330
449
  expect(runs.n).toBe(2)
331
450
  expect(view.get()).toMatchObject({ _tag: 'Error', error: 'boom' })
332
451
  expect(await runtime.runPromise(isStale('FailedCoverQ'))).toBe(true)
@@ -334,7 +453,12 @@ it('a failed refetch leaves its stale mark uncovered, so a fresh subscriber retr
334
453
  // Nothing successful has covered the mark, so the next subscriber still retries.
335
454
  first()
336
455
  view.subscribe(() => {})
337
- await runtime.runPromise(tick())
456
+ await runtime.runPromise(
457
+ until(
458
+ () => view.get(),
459
+ (v) => v._tag === 'Success' && v.refetching === false,
460
+ ),
461
+ )
338
462
  expect(runs.n).toBe(3)
339
463
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 3, refetching: false })
340
464
  await runtime.dispose()
@@ -361,12 +485,22 @@ it('persist: hydration before any request still gets one initial revalidation',
361
485
 
362
486
  const view = forceSync(() => runtime.runSync(Q.store))
363
487
  const stop = rearmOnEveryChange(view.subscribe)
364
- await runtime.runPromise(tick())
488
+ await runtime.runPromise(
489
+ until(
490
+ () => runs.n,
491
+ (n) => n > 0,
492
+ ),
493
+ )
365
494
  expect(runs.n).toBe(1)
366
495
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
367
496
 
368
497
  fresh.resolve(10)
369
- await runtime.runPromise(tick())
498
+ await runtime.runPromise(
499
+ until(
500
+ () => view.get(),
501
+ (v) => v._tag === 'Success' && v.refetching === false,
502
+ ),
503
+ )
370
504
  expect(runs.n).toBe(1)
371
505
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
372
506
  stop()
@@ -397,14 +531,24 @@ it('a genuine key change still enqueues a trailing generation', async () => {
397
531
 
398
532
  const view = forceSync(() => runtime.runSync(Q.store))
399
533
  const count = runtime.runSync(Count.store)
400
- await runtime.runPromise(tick())
534
+ await runtime.runPromise(
535
+ until(
536
+ () => runs.n,
537
+ (n) => n > 0,
538
+ ),
539
+ )
401
540
  expect(runs.n).toBe(1)
402
541
 
403
- count.set(2)
404
- await runtime.runPromise(tick())
542
+ // The change is enqueued, not run: the first generation still owns the slot.
543
+ await runtime.runPromise(afterNotify(count, () => count.set(2)))
405
544
  expect(runs.n).toBe(1)
406
545
  release.resolve()
407
- await runtime.runPromise(tick())
546
+ await runtime.runPromise(
547
+ until(
548
+ () => view.get(),
549
+ (v) => v._tag === 'Success' && v.value === 2 && v.refetching === false,
550
+ ),
551
+ )
408
552
  expect(runs.n).toBe(2)
409
553
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 2, refetching: false })
410
554
  await runtime.dispose()
@@ -431,16 +575,37 @@ it('persist: promise-backed hydration keeps construction synchronous and preserv
431
575
 
432
576
  const view = forceSync(() => runtime.runSync(Q.store))
433
577
  expect(view.get()).toMatchObject({ _tag: 'Loading' })
434
- await runtime.runPromise(tick())
578
+ await runtime.runPromise(
579
+ until(
580
+ () => runs.n,
581
+ (n) => n > 0,
582
+ ),
583
+ )
435
584
  expect(runs.n).toBe(1)
436
585
 
437
586
  cached.resolve(Option.some(99))
438
- await runtime.runPromise(tick())
587
+ await runtime.runPromise(
588
+ until(
589
+ () => view.get(),
590
+ (v) => v._tag === 'Success',
591
+ ),
592
+ )
439
593
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
440
594
 
441
595
  fresh.resolve(10)
442
- await runtime.runPromise(tick())
596
+ await runtime.runPromise(
597
+ until(
598
+ () => view.get(),
599
+ (v) => v._tag === 'Success' && v.refetching === false,
600
+ ),
601
+ )
443
602
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
603
+ await runtime.runPromise(
604
+ until(
605
+ () => store.map.get('P'),
606
+ (persisted) => persisted === 10,
607
+ ),
608
+ )
444
609
  expect(store.map.get('P')).toBe(10)
445
610
  await runtime.dispose()
446
611
  })
@@ -472,10 +637,18 @@ it('persist: late or corrupt hydration cannot overwrite fresh truth or a changed
472
637
  const count = runtime.runSync(Count.store)
473
638
 
474
639
  count.set(2)
475
- await runtime.runPromise(tick())
640
+ await runtime.runPromise(
641
+ until(
642
+ () => view.get(),
643
+ (v) => v._tag === 'Success' && v.value === 20 && v.refetching === false,
644
+ ),
645
+ )
476
646
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 20, refetching: false })
647
+ // Awaiting the same promise, then a scheduler round, lands after every
648
+ // continuation the superseded hydration could still have been holding.
477
649
  cacheByKey.get('P:1')?.resolve(Option.some(99))
478
- await runtime.runPromise(tick())
650
+ await cacheByKey.get('P:1')?.promise
651
+ await runtime.runPromise(scheduled)
479
652
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 20, refetching: false })
480
653
 
481
654
  await runtime.dispose()
@@ -494,12 +667,11 @@ it('persist: late or corrupt hydration cannot overwrite fresh truth or a changed
494
667
  )
495
668
  const churnedView = forceSync(() => churnedRuntime.runSync(Q.store))
496
669
  const churnedCount = churnedRuntime.runSync(Count.store)
497
- churnedCount.set(2)
498
- await churnedRuntime.runPromise(tick())
499
- churnedCount.set(1)
500
- await churnedRuntime.runPromise(tick())
670
+ await churnedRuntime.runPromise(afterNotify(churnedCount, () => churnedCount.set(2)))
671
+ await churnedRuntime.runPromise(afterNotify(churnedCount, () => churnedCount.set(1)))
501
672
  churnedCache.resolve(Option.some(99))
502
- await churnedRuntime.runPromise(tick())
673
+ await churnedCache.promise
674
+ await churnedRuntime.runPromise(scheduled)
503
675
  expect(churnedView.get()).toMatchObject({ _tag: 'Loading' })
504
676
  await churnedRuntime.dispose()
505
677
 
@@ -519,10 +691,16 @@ it('persist: late or corrupt hydration cannot overwrite fresh truth or a changed
519
691
  )
520
692
  const erroredView = forceSync(() => erroredRuntime.runSync(ErroredQ.store))
521
693
  const queries = erroredRuntime.runSync(Queries)
522
- await erroredRuntime.runPromise(tick())
694
+ await erroredRuntime.runPromise(
695
+ until(
696
+ () => erroredView.get(),
697
+ (v) => v._tag === 'Error',
698
+ ),
699
+ )
523
700
  expect(erroredView.get()).toMatchObject({ _tag: 'Error', error: 'fresh-error' })
524
701
  erroredCache.resolve(Option.some(99))
525
- await erroredRuntime.runPromise(tick())
702
+ await erroredCache.promise
703
+ await erroredRuntime.runPromise(scheduled)
526
704
  expect(erroredView.get()).toMatchObject({ _tag: 'Error', error: 'fresh-error' })
527
705
  const erroredHandle = queries.byName.get('AsyncPersistErrored')
528
706
  expect(erroredHandle).toBeDefined()
@@ -546,10 +724,16 @@ it('persist: late or corrupt hydration cannot overwrite fresh truth or a changed
546
724
  )
547
725
  const corruptView = forceSync(() => corruptRuntime.runSync(Q.store))
548
726
  corrupt.resolve(Option.some('not-a-number'))
549
- await corruptRuntime.runPromise(tick())
727
+ await corrupt.promise
728
+ await corruptRuntime.runPromise(scheduled)
550
729
  expect(corruptView.get()).toMatchObject({ _tag: 'Loading' })
551
730
  pendingFresh.resolve(7)
552
- await corruptRuntime.runPromise(tick())
731
+ await corruptRuntime.runPromise(
732
+ until(
733
+ () => corruptView.get(),
734
+ (v) => v._tag === 'Success' && v.refetching === false,
735
+ ),
736
+ )
553
737
  expect(corruptView.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
554
738
  await corruptRuntime.dispose()
555
739
  })
@@ -571,8 +755,11 @@ it('persist: finalization and gating prevent late or unnecessary hydration', asy
571
755
  const view = forceSync(() => runtime.runSync(Q.store))
572
756
  await runtime.dispose()
573
757
 
758
+ // The scope is closed, so awaiting the cache promise is enough: any surviving
759
+ // hydration would resume on that same continuation.
574
760
  cached.resolve(Option.some(99))
575
- await Effect.runPromise(tick())
761
+ await cached.promise
762
+ await Effect.runPromise(scheduled)
576
763
  expect(view.get()).toMatchObject({ _tag: 'Loading' })
577
764
  expect(store.map.size).toBe(0)
578
765
 
@@ -600,12 +787,18 @@ it('persist: finalization and gating prevent late or unnecessary hydration', asy
600
787
  )
601
788
  const disabledView = forceSync(() => disabledRuntime.runSync(DisabledQ.store))
602
789
  const enabled = disabledRuntime.runSync(Enabled.store)
603
- await disabledRuntime.runPromise(tick())
604
790
  expect(disabledView.get()).toMatchObject({ _tag: 'Idle' })
605
791
  expect(disabledCalls).toEqual({ gets: 0, runs: 0 })
606
792
 
793
+ // Enabling is the anchor: once its run has landed, a hydration read during the
794
+ // disabled window would already have shown up in the counts.
607
795
  enabled.set(true)
608
- await disabledRuntime.runPromise(tick())
796
+ await disabledRuntime.runPromise(
797
+ until(
798
+ () => disabledView.get(),
799
+ (v) => v._tag === 'Success',
800
+ ),
801
+ )
609
802
  expect(disabledView.get()).toMatchObject({ _tag: 'Success', value: 1 })
610
803
  expect(disabledCalls).toEqual({ gets: 0, runs: 1 })
611
804
  await disabledRuntime.dispose()
@@ -635,12 +828,22 @@ it('persist: promise hydration is shared by RemoteState and participates in reus
635
828
  const view = forceSync(() => runtime.runSync(Items.store))
636
829
  const cachedItem = { id: 'a', name: 'alpha' }
637
830
  cached.resolve(Option.some([cachedItem]))
638
- await runtime.runPromise(tick())
831
+ await runtime.runPromise(
832
+ until(
833
+ () => view.get(),
834
+ (v) => v._tag === 'Success',
835
+ ),
836
+ )
639
837
  const hydrated = view.get()
640
838
  expect(hydrated).toMatchObject({ _tag: 'Success', refetching: true })
641
839
  if (hydrated._tag === 'Success') {
642
840
  fresh.resolve([{ id: 'a', name: 'alpha' }])
643
- await runtime.runPromise(tick())
841
+ await runtime.runPromise(
842
+ until(
843
+ () => view.get(),
844
+ (v) => v._tag === 'Success' && v.refetching === false,
845
+ ),
846
+ )
644
847
  const settled = view.get()
645
848
  expect(settled).toMatchObject({ _tag: 'Success', refetching: false })
646
849
  if (settled._tag === 'Success') {