@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.
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +26 -23
- package/src/calc/asyncCalc.invalidate.test.ts +258 -55
- package/src/calc/asyncCalc.test.ts +225 -85
- package/src/calc/asyncCalc.ts +27 -141
- package/src/calc/asyncCalcTypes.ts +147 -0
- package/src/calc/calc.test.ts +10 -11
- package/src/calc/calcFamily.test.ts +12 -12
- package/src/channel/channel.ts +8 -106
- package/src/channel/procedures.ts +108 -0
- package/src/compose/composition.ts +49 -246
- package/src/compose/compositionTypes.ts +249 -0
- package/src/event/event.fromSource.test.ts +12 -12
- package/src/feature/feature.ts +50 -1210
- package/src/feature/feature.typecheck.ts +2 -61
- package/src/feature/featureBinding.ts +158 -0
- package/src/feature/featureClass.ts +162 -0
- package/src/feature/featureConfig.ts +192 -0
- package/src/feature/featureFactory.ts +163 -0
- package/src/feature/featureModule.ts +136 -0
- package/src/feature/featureModule.typecheck.ts +63 -0
- package/src/feature/featureNativeTree.ts +152 -0
- package/src/feature/featureRequirement.ts +82 -0
- package/src/feature/featureVariants.ts +234 -0
- package/src/feature/mountFeature.ts +54 -0
- package/src/graph/closure.ts +45 -226
- package/src/graph/services.ts +95 -0
- package/src/graph/summary.ts +134 -0
- package/src/internal/queryDriver.ts +20 -89
- package/src/internal/queryDriverHydrate.ts +45 -0
- package/src/internal/queryDriverTypes.ts +8 -10
- package/src/internal/store.test.ts +27 -6
- package/src/remote/pendingQueue.ts +145 -0
- package/src/remote/remoteState.test.ts +28 -22
- package/src/remote/remoteState.ts +63 -489
- package/src/remote/remoteStateMake.ts +97 -0
- package/src/remote/remoteStateSend.ts +122 -0
- package/src/remote/remoteStateTypes.ts +155 -0
- package/src/runtime/appRuntime.activation.test.ts +9 -5
- package/src/runtime/appRuntime.test.ts +40 -16
- package/src/runtime/appRuntime.ts +17 -472
- package/src/runtime/capturedAppRuntime.ts +274 -0
- package/src/runtime/eventBudget.test.ts +38 -8
- package/src/runtime/featureMount.ts +94 -0
- package/src/runtime/hardening.test.ts +18 -4
- package/src/runtime/instrumentation.test.ts +49 -12
- package/src/runtime/loop.test.ts +51 -10
- package/src/runtime/runtimeHandle.ts +124 -0
- package/src/state/stateFamily.test.ts +25 -8
- 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.
|
|
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,
|
|
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
|
-
|
|
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*
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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*
|
|
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*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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*
|
|
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*
|
|
157
|
+
yield* gate.awaitEntry(2)
|
|
121
158
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
|
|
122
|
-
|
|
123
|
-
|
|
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*
|
|
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*
|
|
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)
|
|
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*
|
|
186
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
404
|
-
await runtime.runPromise(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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') {
|