@effect-app/vue 4.0.0-beta.99 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +2701 -0
  2. package/dist/atomQuery.d.ts +120 -0
  3. package/dist/atomQuery.d.ts.map +1 -0
  4. package/dist/atomQuery.js +434 -0
  5. package/dist/commander.d.ts +318 -43
  6. package/dist/commander.d.ts.map +1 -1
  7. package/dist/commander.js +593 -81
  8. package/dist/confirm.d.ts +5 -3
  9. package/dist/confirm.d.ts.map +1 -1
  10. package/dist/confirm.js +12 -14
  11. package/dist/dependencyMetadata.d.ts +18 -0
  12. package/dist/dependencyMetadata.d.ts.map +1 -0
  13. package/dist/dependencyMetadata.js +67 -0
  14. package/dist/errorReporter.d.ts +6 -4
  15. package/dist/errorReporter.d.ts.map +1 -1
  16. package/dist/errorReporter.js +14 -19
  17. package/dist/form.d.ts +5 -5
  18. package/dist/form.d.ts.map +1 -1
  19. package/dist/form.js +16 -10
  20. package/dist/internal/tanstackQuery.d.ts +9 -0
  21. package/dist/internal/tanstackQuery.d.ts.map +1 -0
  22. package/dist/internal/tanstackQuery.js +204 -0
  23. package/dist/intl.d.ts +4 -4
  24. package/dist/intl.d.ts.map +1 -1
  25. package/dist/intl.js +2 -2
  26. package/dist/lib.d.ts +8 -10
  27. package/dist/lib.d.ts.map +1 -1
  28. package/dist/lib.js +37 -12
  29. package/dist/liveQueryInvalidation.d.ts +16 -0
  30. package/dist/liveQueryInvalidation.d.ts.map +1 -0
  31. package/dist/liveQueryInvalidation.js +115 -0
  32. package/dist/makeClient.d.ts +232 -109
  33. package/dist/makeClient.d.ts.map +1 -1
  34. package/dist/makeClient.js +416 -84
  35. package/dist/makeContext.d.ts.map +1 -1
  36. package/dist/makeIntl.d.ts.map +1 -1
  37. package/dist/makeUseCommand.d.ts +3 -2
  38. package/dist/makeUseCommand.d.ts.map +1 -1
  39. package/dist/makeUseCommand.js +2 -2
  40. package/dist/mutate.d.ts +110 -41
  41. package/dist/mutate.d.ts.map +1 -1
  42. package/dist/mutate.js +236 -59
  43. package/dist/query.d.ts +161 -44
  44. package/dist/query.d.ts.map +1 -1
  45. package/dist/query.js +382 -113
  46. package/dist/queryLifetime.d.ts +25 -0
  47. package/dist/queryLifetime.d.ts.map +1 -0
  48. package/dist/queryLifetime.js +54 -0
  49. package/dist/routeParams.d.ts +4 -4
  50. package/dist/routeParams.d.ts.map +1 -1
  51. package/dist/routeParams.js +4 -3
  52. package/dist/runtime.d.ts +1 -17
  53. package/dist/runtime.d.ts.map +1 -1
  54. package/dist/runtime.js +2 -38
  55. package/dist/suspense.d.ts +19 -0
  56. package/dist/suspense.d.ts.map +1 -0
  57. package/dist/suspense.js +38 -0
  58. package/dist/toast.d.ts +1 -45
  59. package/dist/toast.d.ts.map +1 -1
  60. package/dist/toast.js +2 -32
  61. package/dist/withToast.d.ts +1 -24
  62. package/dist/withToast.d.ts.map +1 -1
  63. package/dist/withToast.js +2 -45
  64. package/docs/atom-query-api-redesign.md +207 -0
  65. package/docs/mutation-command-atoms.md +40 -0
  66. package/docs/query-key-invalidation.md +107 -0
  67. package/examples/streamMutation.ts +72 -0
  68. package/package.json +35 -94
  69. package/src/atomQuery.ts +600 -0
  70. package/src/commander.ts +1064 -116
  71. package/src/confirm.ts +12 -14
  72. package/src/dependencyMetadata.ts +84 -0
  73. package/src/errorReporter.ts +65 -75
  74. package/src/form.ts +22 -15
  75. package/src/index.ts +7 -7
  76. package/src/internal/tanstackQuery.ts +308 -0
  77. package/src/intl.ts +2 -2
  78. package/src/lib.ts +51 -23
  79. package/src/liveQueryInvalidation.ts +138 -0
  80. package/src/makeClient.ts +972 -263
  81. package/src/makeIntl.ts +2 -2
  82. package/src/makeUseCommand.ts +8 -5
  83. package/src/mutate.ts +407 -154
  84. package/src/query.ts +758 -242
  85. package/src/queryLifetime.ts +74 -0
  86. package/src/routeParams.ts +7 -7
  87. package/src/runtime.ts +1 -54
  88. package/src/suspense.ts +43 -0
  89. package/src/toast.ts +1 -52
  90. package/src/withToast.ts +1 -99
  91. package/test/Mutation.test.ts +104 -17
  92. package/test/dependencyInvalidation.test.ts +444 -0
  93. package/test/dist/dependencyInvalidation.test.d.ts.map +1 -0
  94. package/test/dist/form.test.d.ts.map +1 -1
  95. package/test/dist/inactiveInvalidation.test.d.ts.map +1 -0
  96. package/test/dist/interrupt-refetch-repro.test.d.ts.map +1 -0
  97. package/test/dist/lib.test.d.ts.map +1 -0
  98. package/test/dist/liveQueryInvalidation.test.d.ts.map +1 -0
  99. package/test/dist/query-span.test.d.ts.map +1 -0
  100. package/test/dist/queryOptions.test.d.ts.map +1 -0
  101. package/test/dist/streamFinal.test.d.ts.map +1 -0
  102. package/test/dist/streamFn.test.d.ts.map +1 -0
  103. package/test/dist/stubs.d.ts +3681 -256
  104. package/test/dist/stubs.d.ts.map +1 -1
  105. package/test/dist/stubs.js +166 -25
  106. package/test/dist/suspense-regression.test.d.ts.map +1 -0
  107. package/test/dist/suspense.test.d.ts.map +1 -0
  108. package/test/form-validation-errors.test.ts +2 -1
  109. package/test/form.test.ts +2 -1
  110. package/test/inactiveInvalidation.test.ts +172 -0
  111. package/test/interrupt-refetch-repro.test.ts +625 -0
  112. package/test/lib.test.ts +240 -0
  113. package/test/liveQueryInvalidation.test.ts +108 -0
  114. package/test/makeClient.test.ts +414 -32
  115. package/test/query-span.test.ts +92 -0
  116. package/test/queryOptions.test.ts +88 -0
  117. package/test/streamFinal.test.ts +64 -0
  118. package/test/streamFn.test.ts +457 -0
  119. package/test/stubs.ts +194 -36
  120. package/test/suspense-regression.test.ts +168 -0
  121. package/test/suspense.test.ts +160 -0
  122. package/tsconfig.examples.json +20 -0
  123. package/tsconfig.json +9 -1
  124. package/tsconfig.src.json +34 -34
  125. package/tsconfig.test.json +2 -2
  126. package/vitest.config.ts +5 -5
  127. package/eslint.config.mjs +0 -24
  128. package/tsconfig.json.bak +0 -12
@@ -0,0 +1,625 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ //
3
+ // Reproduction matrix: an interrupted query fetch leaves the atom stuck `waiting = true` and
4
+ // frozen on its previous value, so it is never refetched until an explicit invalidation happens.
5
+ //
6
+ // Field incident (Mako Bauhaus PickList): a successful pick's `List` invalidation-refetch was
7
+ // interrupted when the pick-list component re-rendered/unmounted; the atom stayed `Success +
8
+ // waiting=true`, the list kept serving the pre-pick snapshot, and — because every subsequent pick
9
+ // then failed the backend `picking` guard (no write → no further invalidation) — nothing ever
10
+ // cleared it. Pickers re-confirmed already-picked items and hit
11
+ // "Artikel ist nicht im Kommissionierstatus".
12
+ //
13
+ // Root cause: `isStaleResult` returns false while `r.waiting` is true (atomQuery.ts), so a query
14
+ // whose fetch was interrupted (waiting never cleared) is neither completing nor considered stale —
15
+ // mount/focus/reconnect refetch triggers all skip it. Only `Reactivity.invalidate` (an explicit
16
+ // invalidation) force-refreshes it.
17
+ //
18
+ // `it.fails` = a wedge we want a fix to flip to green (asserts the DESIRED recovery, currently
19
+ // fails). Plain `it` = behaviour that already works / a characterization of the stuck state.
20
+ //
21
+ import { defaultRegistry, registryKey } from "@effect/atom-vue"
22
+ import { DataDependencies, makeQueryKey } from "effect-app/client"
23
+ import * as Context from "effect-app/Context"
24
+ import * as Effect from "effect-app/Effect"
25
+ import * as Option from "effect-app/Option"
26
+ import * as Fiber from "effect/Fiber"
27
+ import * as Latch from "effect/Latch"
28
+ import * as Layer from "effect/Layer"
29
+ import * as ManagedRuntime from "effect/ManagedRuntime"
30
+ import * as AsyncResult from "effect/reactivity/AsyncResult"
31
+ import * as Reactivity from "effect/reactivity/Reactivity"
32
+ import { createApp, nextTick } from "vue"
33
+ import { awaitAtomResult, buildQueryFamily, invalidateAndAwait, makeAtomClientRuntime, queryFetchStates, withQueryOptions } from "../src/atomQuery.js"
34
+ import { invalidateQueries } from "../src/mutate.js"
35
+ import { useAtomSuspense } from "../src/query.js"
36
+
37
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
38
+ const ticks = async (n: number) => {
39
+ for (let i = 0; i < n; i++) {
40
+ await nextTick()
41
+ await tick()
42
+ }
43
+ }
44
+
45
+ const makeEnv = () => {
46
+ const mrt = ManagedRuntime.make(Reactivity.layer)
47
+ const baseContext = mrt.runSync(Effect.context<Reactivity.Reactivity>())
48
+ const reactivity = Context.get(baseContext, Reactivity.Reactivity)
49
+ const rt = makeAtomClientRuntime(
50
+ () => Layer.succeedContext(baseContext) as Layer.Layer<any, never, never>,
51
+ mrt.memoMap
52
+ )
53
+ const invalidator = {
54
+ invalidateAndAwait: (keys: ReadonlyArray<ReadonlyArray<unknown>>) =>
55
+ invalidateAndAwait(keys).pipe(Effect.provideService(Reactivity.Reactivity, reactivity))
56
+ }
57
+ return { rt, invalidator }
58
+ }
59
+
60
+ // A query handler that records a repo read and (when armed) blocks each fetch on a fresh Latch, so
61
+ // a test can hold a fetch in-flight and then interrupt it (by unmounting the subscriber). The
62
+ // returned value is the fetch's start ordinal, so the served value tells us which fetch produced
63
+ // the currently-cached data (`served === 1` means "still the first, stale value").
64
+ const makeGated = (id: string, repo: DataDependencies.DataDependency) => {
65
+ let starts = 0
66
+ let completes = 0
67
+ let blocking = false
68
+ const latches: Array<Latch.Latch> = []
69
+ const self = {
70
+ id,
71
+ handler: () =>
72
+ Effect.gen(function*() {
73
+ yield* DataDependencies.read(repo)
74
+ const n = ++starts
75
+ if (blocking) {
76
+ const latch = Latch.makeUnsafe(false)
77
+ latches.push(latch)
78
+ yield* latch.await
79
+ }
80
+ completes++
81
+ return n
82
+ })
83
+ }
84
+ return {
85
+ self,
86
+ starts: () => starts,
87
+ completes: () => completes,
88
+ setBlocking: (v: boolean) => {
89
+ blocking = v
90
+ },
91
+ releaseAll: () => latches.splice(0).forEach((latch) => latch.openUnsafe())
92
+ }
93
+ }
94
+
95
+ const result = (atom: any): AsyncResult.AsyncResult<number, never> => defaultRegistry.get(atom)
96
+ const served = (atom: any): number | undefined => Option.getOrUndefined(AsyncResult.value(result(atom)))
97
+
98
+ // Default staleTime (5s): a remount within a test does NOT auto-refetch on staleness, so any refetch
99
+ // on remount is attributable to invalidation / interrupt handling — not swr staleness. gcTime
100
+ // "infinity" keeps the atom (and its cached value) alive across unmount→remount, matching a
101
+ // real cache-hit remount.
102
+ const queryAtom = (rt: any, self: any) =>
103
+ withQueryOptions(buildQueryFamily(rt, self)(undefined), { gcTime: "infinity", revalidateOnFocus: false })
104
+
105
+ const mountSuspense = (atom: any) => {
106
+ let promise: Promise<any> | undefined
107
+ const host = document.createElement("div")
108
+ const app = createApp({
109
+ setup() {
110
+ promise = useAtomSuspense(() => atom)
111
+ return () => null
112
+ }
113
+ })
114
+ app.provide(registryKey, defaultRegistry)
115
+ app.mount(host)
116
+ return { app, getPromise: () => promise }
117
+ }
118
+
119
+ const settle = (p: Promise<any> | undefined) =>
120
+ Promise.race([
121
+ Promise.resolve(p).then(() => "resolved" as const, () => "rejected" as const),
122
+ new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 40))
123
+ ])
124
+
125
+ const ignoreSuspenseInterrupt = (p: Promise<any> | undefined) => {
126
+ p?.catch(() => {})
127
+ }
128
+
129
+ const writeCommand = (repo: DataDependencies.DataDependency) => DataDependencies.write(repo).pipe(Effect.as(undefined))
130
+ const drain = (fiber: Fiber.Fiber<any, any>) =>
131
+ Effect.runPromise(Fiber.interrupt(fiber) as any).then(() => {}, () => {})
132
+
133
+ // ===================================================================================
134
+ // A. Interrupt on suspense page unload (query-side)
135
+ // ===================================================================================
136
+
137
+ it.fails("A1 — unload during the INITIAL fetch: remount must refetch, not hang forever", async () => {
138
+ defaultRegistry.reset()
139
+ const { rt } = makeEnv()
140
+ const g = makeGated("Repro.A1.List", DataDependencies.repo("A1Repo"))
141
+ const atom = queryAtom(rt, g.self)
142
+
143
+ g.setBlocking(true) // initial fetch hangs
144
+ const m1 = mountSuspense(atom)
145
+ await ticks(2)
146
+ expect(g.starts()).toBe(1)
147
+
148
+ ignoreSuspenseInterrupt(m1.getPromise())
149
+ m1.app.unmount() // page unload -> interrupt the in-flight initial fetch
150
+ await ticks(2)
151
+
152
+ g.setBlocking(false)
153
+ const m2 = mountSuspense(atom) // remount; there is NO cached value, so it MUST fetch
154
+ const outcome = await settle(m2.getPromise())
155
+ await ticks(4)
156
+
157
+ // WEDGE: the interrupted initial fetch leaves the atom Initial+waiting=true; remount never
158
+ // re-runs (starts stays 1) and the suspense promise hangs.
159
+ expect(g.starts(), "remount with no cached value must start a fresh fetch").toBeGreaterThanOrEqual(2)
160
+ expect(outcome).toBe("resolved")
161
+ m2.app.unmount()
162
+ defaultRegistry.reset()
163
+ })
164
+
165
+ it.fails("A2 — seeded, unload during a REFETCH: remount alone must serve fresh (field wedge)", async () => {
166
+ defaultRegistry.reset()
167
+ const { rt } = makeEnv()
168
+ const g = makeGated("Repro.A2.List", DataDependencies.repo("A2Repo"))
169
+ const atom = queryAtom(rt, g.self)
170
+
171
+ const m1 = mountSuspense(atom)
172
+ await settle(m1.getPromise())
173
+ await ticks(2)
174
+ expect(served(atom), "seed").toBe(1)
175
+
176
+ g.setBlocking(true)
177
+ defaultRegistry.refresh(atom) // refetch #2 starts, blocks
178
+ await ticks(2)
179
+ m1.app.unmount() // page unload -> interrupt the in-flight refetch #2
180
+ await ticks(2)
181
+ g.setBlocking(false)
182
+
183
+ const m2 = mountSuspense(atom) // remount (cache-hit)
184
+ await settle(m2.getPromise())
185
+ await ticks(4)
186
+
187
+ // WEDGE: atom is stuck Success+waiting=true, so remount serves the stale value 1 and never refetches.
188
+ expect(served(atom), "remount must recover past the stale value 1").not.toBe(1)
189
+ m2.app.unmount()
190
+ defaultRegistry.reset()
191
+ })
192
+
193
+ it("A2b — characterization: an interrupted refetch leaves the atom Success + waiting=true (never stale)", async () => {
194
+ defaultRegistry.reset()
195
+ const { rt } = makeEnv()
196
+ const g = makeGated("Repro.A2b.List", DataDependencies.repo("A2bRepo"))
197
+ const atom = queryAtom(rt, g.self)
198
+
199
+ const m1 = mountSuspense(atom)
200
+ await settle(m1.getPromise())
201
+ await ticks(2)
202
+
203
+ g.setBlocking(true)
204
+ defaultRegistry.refresh(atom)
205
+ await ticks(2)
206
+ m1.app.unmount() // interrupt refetch
207
+ await ticks(2)
208
+
209
+ const r = result(atom)
210
+ expect(AsyncResult.isSuccess(r)).toBe(true)
211
+ expect(served(atom)).toBe(1) // frozen on the stale previous value
212
+ expect(r.waiting, "the stuck `waiting=true` is why isStaleResult never marks it refetchable").toBe(true)
213
+ defaultRegistry.reset()
214
+ })
215
+
216
+ // ===================================================================================
217
+ // B. Interrupt during a mutation (command done; invalidation about to / mid-flight)
218
+ // ===================================================================================
219
+
220
+ it("B1 — mutation fiber interrupted while its onExit invalidation-refetch runs: query still refreshes", async () => {
221
+ defaultRegistry.reset()
222
+ const { rt, invalidator } = makeEnv()
223
+ const repo = DataDependencies.repo("B1Repo")
224
+ const g = makeGated("Repro.B1.List", repo)
225
+ const atom = buildQueryFamily(rt as any, g.self as any)(undefined)
226
+ const unmount = defaultRegistry.mount(atom)
227
+
228
+ await Effect.runPromise(awaitAtomResult(defaultRegistry, atom)) // fetch #1
229
+ expect(g.completes()).toBe(1)
230
+
231
+ g.setBlocking(true)
232
+ const mutation = invalidateQueries({ id: "Repro.B1.Save" }, undefined, invalidator)(writeCommand(repo), { id: "x" })
233
+ const fiber = Effect.runFork(mutation)
234
+ await ticks(2)
235
+ expect(g.starts(), "invalidation should have started the refetch").toBe(2)
236
+
237
+ // The onExit invalidation runs uninterruptibly, so releasing the refetch completes it even though
238
+ // the mutation fiber is interrupted.
239
+ const interrupting = drain(fiber)
240
+ g.releaseAll()
241
+ await interrupting
242
+ await Effect.runPromise(awaitAtomResult(defaultRegistry, atom))
243
+
244
+ expect(g.completes(), "onExit invalidation must complete the refetch despite fiber interrupt").toBe(2)
245
+ expect(served(atom)).toBe(2)
246
+ unmount()
247
+ defaultRegistry.reset()
248
+ })
249
+
250
+ // Contrast with A2: the wedge is specific to refetches NOT backed by a durable invalidation
251
+ // dirty-mark. A mutation's `Reactivity.invalidate` dirty-marks the atom, and that mark survives the
252
+ // interrupt, so a remount refetches. (The field stayed wedged because it either did not remount the
253
+ // query scope — see A2b — or the only trigger that would re-invalidate, a successful pick, could
254
+ // never happen against the stale list.)
255
+ it("B2 — query unmounts DURING a mutation-triggered refetch, then remounts: recovers (invalidation dirty-mark survives)", async () => {
256
+ defaultRegistry.reset()
257
+ const { rt, invalidator } = makeEnv()
258
+ const repo = DataDependencies.repo("B2Repo")
259
+ const g = makeGated("Repro.B2.List", repo)
260
+ const atom = queryAtom(rt, g.self)
261
+
262
+ const m1 = mountSuspense(atom)
263
+ await settle(m1.getPromise())
264
+ await ticks(2)
265
+ expect(served(atom), "seed").toBe(1)
266
+
267
+ g.setBlocking(true)
268
+ const mutation = invalidateQueries({ id: "Repro.B2.Save" }, undefined, invalidator)(writeCommand(repo), { id: "x" })
269
+ const fiber = Effect.runFork(mutation)
270
+ await ticks(2)
271
+ expect(g.starts(), "mutation should have started the refetch").toBe(2)
272
+
273
+ m1.app.unmount() // query component unmounts mid-refetch (dialog-close re-render) -> interrupt
274
+ await ticks(2)
275
+ g.releaseAll()
276
+ await drain(fiber)
277
+ g.setBlocking(false)
278
+
279
+ const m2 = mountSuspense(atom) // remount; no further successful mutation comes (as in the field)
280
+ await settle(m2.getPromise())
281
+ await ticks(4)
282
+
283
+ // WEDGE: stuck Success+waiting=true -> the pick list stays frozen on the pre-pick snapshot.
284
+ expect(served(atom), "remount must recover past the stale value 1").not.toBe(1)
285
+ m2.app.unmount()
286
+ defaultRegistry.reset()
287
+ })
288
+
289
+ it("B3 — query UNMOUNTED when the mutation invalidation lands, then remounts: refetches fresh", async () => {
290
+ defaultRegistry.reset()
291
+ const { rt, invalidator } = makeEnv()
292
+ const repo = DataDependencies.repo("B3Repo")
293
+ const g = makeGated("Repro.B3.List", repo)
294
+ const atom = queryAtom(rt, g.self)
295
+
296
+ const m1 = mountSuspense(atom)
297
+ await settle(m1.getPromise())
298
+ await ticks(2)
299
+ expect(served(atom), "seed").toBe(1)
300
+
301
+ m1.app.unmount() // navigate away: query is unmounted (cached)
302
+ await ticks(2)
303
+
304
+ // Command writes the repo; invalidation marks the (unmounted) query dirty
305
+ // without refetching it (TanStack refetchType: "active").
306
+ const mutation = invalidateQueries({ id: "Repro.B3.Save" }, undefined, invalidator)(writeCommand(repo), { id: "x" })
307
+ await Effect.runPromise(mutation as any).then(() => {}, () => {})
308
+ await ticks(2)
309
+ expect(g.starts(), "unmounted invalidation must not refetch").toBe(1)
310
+
311
+ const m2 = mountSuspense(atom) // remount: an invalidated query refetches on mount
312
+ await settle(m2.getPromise())
313
+ await ticks(2)
314
+
315
+ expect(g.starts(), "remount of an invalidated query must refetch").toBeGreaterThanOrEqual(2)
316
+ expect(served(atom)).not.toBe(1)
317
+ m2.app.unmount()
318
+ defaultRegistry.reset()
319
+ })
320
+
321
+ // ===================================================================================
322
+ // M. Multi-caller invariants: an interrupt models "this caller lost interest" — it must not
323
+ // cascade to concurrent callers, and later callers must not inherit a poisoned result.
324
+ // ===================================================================================
325
+
326
+ it("M1 — concurrent callers share ONE in-flight fetch (re-entrant join)", async () => {
327
+ defaultRegistry.reset()
328
+ const { rt } = makeEnv()
329
+ const g = makeGated("Repro.M1.List", DataDependencies.repo("M1Repo"))
330
+ const atom = queryAtom(rt, g.self)
331
+
332
+ g.setBlocking(true)
333
+ const a = mountSuspense(atom) // starts fetch #1
334
+ await ticks(2)
335
+ const b = mountSuspense(atom) // joins the same in-flight fetch
336
+ await ticks(2)
337
+ expect(g.starts(), "second caller must join, not start a second fetch").toBe(1)
338
+
339
+ g.releaseAll()
340
+ expect(await settle(a.getPromise())).toBe("resolved")
341
+ expect(await settle(b.getPromise())).toBe("resolved")
342
+ expect(g.completes()).toBe(1)
343
+ a.app.unmount()
344
+ b.app.unmount()
345
+ defaultRegistry.reset()
346
+ })
347
+
348
+ it("M2 — one of two concurrent callers unmounts mid-fetch: the other still resolves (no cascade)", async () => {
349
+ defaultRegistry.reset()
350
+ const { rt } = makeEnv()
351
+ const g = makeGated("Repro.M2.List", DataDependencies.repo("M2Repo"))
352
+ const atom = queryAtom(rt, g.self)
353
+
354
+ g.setBlocking(true)
355
+ const a = mountSuspense(atom) // fetch #1 in-flight
356
+ await ticks(2)
357
+ const b = mountSuspense(atom) // joins
358
+ await ticks(2)
359
+ expect(g.starts()).toBe(1)
360
+
361
+ ignoreSuspenseInterrupt(a.getPromise())
362
+ a.app.unmount() // caller A loses interest -> interrupts ITS await only
363
+ await ticks(2)
364
+ g.releaseAll() // the shared fetch must survive for caller B
365
+
366
+ const outcomeB = await settle(b.getPromise())
367
+ await ticks(2)
368
+ expect(outcomeB, "A's interrupt must not cascade to B").toBe("resolved")
369
+ expect(served(atom)).toBe(1)
370
+ expect(g.completes()).toBe(1)
371
+ b.app.unmount()
372
+ defaultRegistry.reset()
373
+ })
374
+
375
+ // ===================================================================================
376
+ // FIX. recoverStuckWaitingOnMount: a query whose result says `waiting` while nothing is in-flight
377
+ // (`inFlight === 0`) is a hidden interrupt left dangling — effect-core removed the result
378
+ // observer before interrupting the fiber. Such an atom must be treated as not-yet-fetched on
379
+ // the next mount and refetched, re-entrantly (concurrent mounts share the single recovery
380
+ // fetch — the `recovering` guard). We do NOT auto-retry the interrupt; recovery is driven by a
381
+ // genuine (re)mount. This validates the recovery mechanism directly.
382
+ // ===================================================================================
383
+
384
+ // Put an atom into the stuck state: a refetch is issued, then we simulate effect-core's
385
+ // interrupt-hiding — the result stays `waiting` but `inFlight` is forced to 0 (nothing running).
386
+ const induceStuckWaiting = async (g: ReturnType<typeof makeGated>, atom: any, familyAtom: any) => {
387
+ g.setBlocking(true)
388
+ defaultRegistry.refresh(atom)
389
+ await ticks(2)
390
+ expect((result(atom) as any).waiting).toBe(true)
391
+ queryFetchStates.get(familyAtom)!.inFlight = 0
392
+ g.setBlocking(false)
393
+ }
394
+
395
+ it("FIX2 — concurrent mounts of a stuck query share ONE recovery fetch (re-entrant)", async () => {
396
+ defaultRegistry.reset()
397
+ const { rt } = makeEnv()
398
+ const g = makeGated("Repro.FIX2.List", DataDependencies.repo("FIX2Repo"))
399
+ const familyAtom = buildQueryFamily(rt as any, g.self as any)(undefined)
400
+ const atom = withQueryOptions(familyAtom, { gcTime: "infinity", revalidateOnFocus: false })
401
+
402
+ const m0 = mountSuspense(atom)
403
+ await settle(m0.getPromise())
404
+ await ticks(2)
405
+
406
+ await induceStuckWaiting(g, atom, familyAtom)
407
+ m0.app.unmount()
408
+ await ticks(2)
409
+ g.setBlocking(true) // hold the recovery fetch so both callers are in-flight together
410
+ // Baseline AFTER inducing the wedge (induceStuckWaiting itself starts one — then interrupted — fetch),
411
+ // so the delta counts only the recovery fetch the mounts trigger.
412
+ const before = g.starts()
413
+
414
+ const a = mountSuspense(atom)
415
+ const b = mountSuspense(atom)
416
+ await ticks(2)
417
+ expect(g.starts() - before, "both callers must share ONE recovery fetch").toBe(1)
418
+
419
+ g.releaseAll()
420
+ expect(await settle(a.getPromise())).toBe("resolved")
421
+ expect(await settle(b.getPromise())).toBe("resolved")
422
+ a.app.unmount()
423
+ b.app.unmount()
424
+ defaultRegistry.reset()
425
+ })
426
+
427
+ it("FIX3 — a result stuck `waiting` with no in-flight fetch is treated as stuck and refetched", async () => {
428
+ defaultRegistry.reset()
429
+ const { rt } = makeEnv()
430
+ const g = makeGated("Repro.FIX3.List", DataDependencies.repo("FIX3Repo"))
431
+ const familyAtom = buildQueryFamily(rt as any, g.self as any)(undefined)
432
+ const atom = withQueryOptions(familyAtom, { gcTime: "infinity", revalidateOnFocus: false })
433
+
434
+ const m1 = mountSuspense(atom)
435
+ await settle(m1.getPromise())
436
+ await ticks(2)
437
+ expect(served(atom)).toBe(1)
438
+
439
+ // Put the atom into a `waiting` state (a refetch), then simulate effect-core's interrupt-hiding:
440
+ // the fetch fiber is interrupted (so nothing is in-flight) but the result was left `waiting`.
441
+ g.setBlocking(true)
442
+ defaultRegistry.refresh(atom)
443
+ await ticks(2)
444
+ expect((result(atom) as any).waiting).toBe(true)
445
+ queryFetchStates.get(familyAtom)!.inFlight = 0 // effect-core removed its observer before interrupting → waiting is now dangling
446
+ m1.app.unmount()
447
+ await ticks(2)
448
+ g.setBlocking(false)
449
+
450
+ const m2 = mountSuspense(atom) // remount: stuck (waiting, inFlight 0) must refetch, not adopt it
451
+ await settle(m2.getPromise())
452
+ await ticks(2)
453
+
454
+ expect(g.starts(), "stuck waiting must be refetched").toBeGreaterThanOrEqual(2)
455
+ expect(served(atom), "serves the fresh value, not the stuck one").not.toBe(1)
456
+ m2.app.unmount()
457
+ defaultRegistry.reset()
458
+ })
459
+
460
+ // ===================================================================================
461
+ // SUP. Supersession: 3 listeners share 1 in-flight fetch; 1 leaves; a mutation makes the in-flight
462
+ // fetch stale, so it is interrupted and re-queried; the 2 remaining listeners join the new fetch.
463
+ // ===================================================================================
464
+
465
+ // GUARD (already correct): once a query has completed once (so its read-deps are recorded), a
466
+ // mutation whose `Reactivity.invalidate` lands while a refetch is in-flight DOES supersede that
467
+ // stale in-flight fetch and re-query; the remaining listeners join the fresh fetch. (An earlier
468
+ // "invalidation dropped" reading was a test artifact: a query whose *seed* fetch is blocked never
469
+ // records read-deps, so the dependency-derived invalidation never targets it — no invalidation
470
+ // fires at all. With a completed seed, supersession works.)
471
+ it("SUP — invalidation supersedes a stale in-flight fetch; remaining listeners join the fresh one", async () => {
472
+ defaultRegistry.reset()
473
+ const { rt, invalidator } = makeEnv()
474
+ const repo = DataDependencies.repo("SUPRepo")
475
+ const g = makeGated("Repro.SUP.List", repo)
476
+ const atom = queryAtom(rt, g.self)
477
+
478
+ // 3 listeners; seed a COMPLETED fetch #1 so the query records its read-deps (required for the
479
+ // mutation's dependency-derived invalidation to target it).
480
+ const a = mountSuspense(atom)
481
+ const b = mountSuspense(atom)
482
+ const c = mountSuspense(atom)
483
+ await settle(a.getPromise())
484
+ await ticks(2)
485
+ expect(g.starts(), "3 listeners share one fetch").toBe(1)
486
+ expect(served(atom)).toBe(1)
487
+
488
+ // Now a refetch is IN-FLIGHT (e.g. a live-event/staleness refresh) — this is the "stale in-flight" fetch.
489
+ g.setBlocking(true)
490
+ defaultRegistry.refresh(atom)
491
+ await ticks(2)
492
+ expect(g.starts(), "refetch #2 in-flight").toBe(2)
493
+
494
+ // 1 listener loses interest — the shared in-flight fetch survives for the other two.
495
+ a.app.unmount()
496
+ await ticks(2)
497
+
498
+ // A mutation invalidates the query while fetch #2 is still in-flight (stale) -> must supersede it.
499
+ const mutation = invalidateQueries({ id: "Repro.SUP.Save" }, undefined, invalidator)(writeCommand(repo), { id: "x" })
500
+ const fiber = Effect.runFork(mutation)
501
+ await ticks(2)
502
+ const superseded = g.starts()
503
+
504
+ // Cleanup before asserting so the forked mutation never dangles.
505
+ g.releaseAll()
506
+ await drain(fiber)
507
+ await settle(b.getPromise())
508
+ await settle(c.getPromise())
509
+ await ticks(2)
510
+ const finalServed = served(atom)
511
+ b.app.unmount()
512
+ c.app.unmount()
513
+ defaultRegistry.reset()
514
+
515
+ // DESIRED: the in-flight fetch #2 was superseded by a fresh fetch #3, and the remaining listeners
516
+ // see a value from a fetch that ran AFTER the mutation.
517
+ expect(superseded, "invalidation must supersede the stale in-flight fetch").toBeGreaterThanOrEqual(3)
518
+ expect(finalServed, "remaining listeners must see a post-mutation fetch").toBe(g.starts())
519
+ expect(finalServed).not.toBe(1)
520
+ })
521
+
522
+ // ===================================================================================
523
+ // C. Recovery guard — the escape hatch the field never got
524
+ // ===================================================================================
525
+
526
+ it("C1 — an explicit invalidation after an interrupted refetch DOES recover the query", async () => {
527
+ defaultRegistry.reset()
528
+ const { rt, invalidator } = makeEnv()
529
+ const repo = DataDependencies.repo("C1Repo")
530
+ const g = makeGated("Repro.C1.List", repo)
531
+ const atom = queryAtom(rt, g.self)
532
+ const fullKey = [...makeQueryKey(g.self), undefined]
533
+
534
+ const m1 = mountSuspense(atom)
535
+ await settle(m1.getPromise())
536
+ await ticks(2)
537
+
538
+ g.setBlocking(true)
539
+ defaultRegistry.refresh(atom)
540
+ await ticks(2)
541
+ m1.app.unmount() // interrupt refetch -> stuck Success+waiting=true
542
+ await ticks(2)
543
+ g.setBlocking(false)
544
+
545
+ const m2 = mountSuspense(atom)
546
+ await settle(m2.getPromise())
547
+ await ticks(2)
548
+ expect(served(atom), "still stale before the explicit invalidation").toBe(1)
549
+
550
+ // A subsequent invalidation (a later successful mutation) force-refreshes and unsticks it.
551
+ await Effect.runPromise(invalidator.invalidateAndAwait([fullKey]) as any).then(() => {}, () => {})
552
+ await ticks(2)
553
+
554
+ expect(g.completes()).toBeGreaterThanOrEqual(2)
555
+ expect(served(atom), "explicit invalidation recovers fresh data").not.toBe(1)
556
+ m2.app.unmount()
557
+ defaultRegistry.reset()
558
+ })
559
+
560
+ // ===================================================================================
561
+ // D. Desired listener semantics (user spec):
562
+ // D1 — a truly-interrupted in-flight request must not be joined by a later listener; the later
563
+ // listener starts fresh (never adopts / hangs on the abandoned request).
564
+ // D2 — with a prior success, an interrupted re-query must not strand a later listener: it should
565
+ // serve the stale previous value immediately AND revalidate in the background (SWR).
566
+ // ===================================================================================
567
+
568
+ it.fails("D1 — sole listener interrupts an in-flight request; a NEW listener starts fresh (no join of the abandoned request)", async () => {
569
+ defaultRegistry.reset()
570
+ const { rt } = makeEnv()
571
+ const g = makeGated("Repro.D1.List", DataDependencies.repo("D1Repo"))
572
+ const atom = queryAtom(rt, g.self)
573
+
574
+ g.setBlocking(true)
575
+ const l1 = mountSuspense(atom) // fetch #1 in-flight
576
+ await ticks(2)
577
+ expect(g.starts()).toBe(1)
578
+
579
+ ignoreSuspenseInterrupt(l1.getPromise())
580
+ l1.app.unmount() // the only interested caller leaves -> the in-flight request should be abandoned
581
+ await ticks(2)
582
+ g.setBlocking(false)
583
+
584
+ const l2 = mountSuspense(atom) // a different listener queries the same key
585
+ const outcome = await settle(l2.getPromise())
586
+ await ticks(2)
587
+
588
+ // DESIRED: l2 starts a FRESH fetch rather than joining the abandoned one (which never completes).
589
+ expect(g.starts(), "new listener must start fresh, not join the abandoned request").toBeGreaterThanOrEqual(2)
590
+ expect(outcome, "new listener must resolve, not hang on a dead request").toBe("resolved")
591
+ l2.app.unmount()
592
+ defaultRegistry.reset()
593
+ })
594
+
595
+ it.fails("D2 — prior success; an interrupted re-query serves a later listener the stale value immediately, then revalidates (SWR)", async () => {
596
+ defaultRegistry.reset()
597
+ const { rt } = makeEnv()
598
+ const g = makeGated("Repro.D2.List", DataDependencies.repo("D2Repo"))
599
+ const atom = queryAtom(rt, g.self)
600
+
601
+ const l1 = mountSuspense(atom) // fetch #1 -> previousSuccess = 1 (saved)
602
+ await settle(l1.getPromise())
603
+ await ticks(2)
604
+ expect(served(atom)).toBe(1)
605
+
606
+ g.setBlocking(true)
607
+ defaultRegistry.refresh(atom) // same listener re-queries
608
+ await ticks(2)
609
+ l1.app.unmount() // ...and interrupts the ongoing re-query
610
+ await ticks(2)
611
+ g.setBlocking(false)
612
+
613
+ const l2 = mountSuspense(atom) // another listener queries
614
+ await settle(l2.getPromise())
615
+ const immediate = served(atom)
616
+ await ticks(4)
617
+ const afterRevalidate = served(atom)
618
+
619
+ // DESIRED: stale-while-revalidate — the previous value shows at once, then a background fetch refreshes it.
620
+ expect(immediate, "stale previous result shown immediately").toBe(1)
621
+ expect(g.starts(), "a background revalidation fetch must run").toBeGreaterThanOrEqual(2)
622
+ expect(afterRevalidate, "background revalidate replaces the stale value with fresh data").not.toBe(1)
623
+ l2.app.unmount()
624
+ defaultRegistry.reset()
625
+ })