@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.
- package/CHANGELOG.md +2701 -0
- package/dist/atomQuery.d.ts +120 -0
- package/dist/atomQuery.d.ts.map +1 -0
- package/dist/atomQuery.js +434 -0
- package/dist/commander.d.ts +318 -43
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +593 -81
- package/dist/confirm.d.ts +5 -3
- package/dist/confirm.d.ts.map +1 -1
- package/dist/confirm.js +12 -14
- package/dist/dependencyMetadata.d.ts +18 -0
- package/dist/dependencyMetadata.d.ts.map +1 -0
- package/dist/dependencyMetadata.js +67 -0
- package/dist/errorReporter.d.ts +6 -4
- package/dist/errorReporter.d.ts.map +1 -1
- package/dist/errorReporter.js +14 -19
- package/dist/form.d.ts +5 -5
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +16 -10
- package/dist/internal/tanstackQuery.d.ts +9 -0
- package/dist/internal/tanstackQuery.d.ts.map +1 -0
- package/dist/internal/tanstackQuery.js +204 -0
- package/dist/intl.d.ts +4 -4
- package/dist/intl.d.ts.map +1 -1
- package/dist/intl.js +2 -2
- package/dist/lib.d.ts +8 -10
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +37 -12
- package/dist/liveQueryInvalidation.d.ts +16 -0
- package/dist/liveQueryInvalidation.d.ts.map +1 -0
- package/dist/liveQueryInvalidation.js +115 -0
- package/dist/makeClient.d.ts +232 -109
- package/dist/makeClient.d.ts.map +1 -1
- package/dist/makeClient.js +416 -84
- package/dist/makeContext.d.ts.map +1 -1
- package/dist/makeIntl.d.ts.map +1 -1
- package/dist/makeUseCommand.d.ts +3 -2
- package/dist/makeUseCommand.d.ts.map +1 -1
- package/dist/makeUseCommand.js +2 -2
- package/dist/mutate.d.ts +110 -41
- package/dist/mutate.d.ts.map +1 -1
- package/dist/mutate.js +236 -59
- package/dist/query.d.ts +161 -44
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +382 -113
- package/dist/queryLifetime.d.ts +25 -0
- package/dist/queryLifetime.d.ts.map +1 -0
- package/dist/queryLifetime.js +54 -0
- package/dist/routeParams.d.ts +4 -4
- package/dist/routeParams.d.ts.map +1 -1
- package/dist/routeParams.js +4 -3
- package/dist/runtime.d.ts +1 -17
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +2 -38
- package/dist/suspense.d.ts +19 -0
- package/dist/suspense.d.ts.map +1 -0
- package/dist/suspense.js +38 -0
- package/dist/toast.d.ts +1 -45
- package/dist/toast.d.ts.map +1 -1
- package/dist/toast.js +2 -32
- package/dist/withToast.d.ts +1 -24
- package/dist/withToast.d.ts.map +1 -1
- package/dist/withToast.js +2 -45
- package/docs/atom-query-api-redesign.md +207 -0
- package/docs/mutation-command-atoms.md +40 -0
- package/docs/query-key-invalidation.md +107 -0
- package/examples/streamMutation.ts +72 -0
- package/package.json +35 -94
- package/src/atomQuery.ts +600 -0
- package/src/commander.ts +1064 -116
- package/src/confirm.ts +12 -14
- package/src/dependencyMetadata.ts +84 -0
- package/src/errorReporter.ts +65 -75
- package/src/form.ts +22 -15
- package/src/index.ts +7 -7
- package/src/internal/tanstackQuery.ts +308 -0
- package/src/intl.ts +2 -2
- package/src/lib.ts +51 -23
- package/src/liveQueryInvalidation.ts +138 -0
- package/src/makeClient.ts +972 -263
- package/src/makeIntl.ts +2 -2
- package/src/makeUseCommand.ts +8 -5
- package/src/mutate.ts +407 -154
- package/src/query.ts +758 -242
- package/src/queryLifetime.ts +74 -0
- package/src/routeParams.ts +7 -7
- package/src/runtime.ts +1 -54
- package/src/suspense.ts +43 -0
- package/src/toast.ts +1 -52
- package/src/withToast.ts +1 -99
- package/test/Mutation.test.ts +104 -17
- package/test/dependencyInvalidation.test.ts +444 -0
- package/test/dist/dependencyInvalidation.test.d.ts.map +1 -0
- package/test/dist/form.test.d.ts.map +1 -1
- package/test/dist/inactiveInvalidation.test.d.ts.map +1 -0
- package/test/dist/interrupt-refetch-repro.test.d.ts.map +1 -0
- package/test/dist/lib.test.d.ts.map +1 -0
- package/test/dist/liveQueryInvalidation.test.d.ts.map +1 -0
- package/test/dist/query-span.test.d.ts.map +1 -0
- package/test/dist/queryOptions.test.d.ts.map +1 -0
- package/test/dist/streamFinal.test.d.ts.map +1 -0
- package/test/dist/streamFn.test.d.ts.map +1 -0
- package/test/dist/stubs.d.ts +3681 -256
- package/test/dist/stubs.d.ts.map +1 -1
- package/test/dist/stubs.js +166 -25
- package/test/dist/suspense-regression.test.d.ts.map +1 -0
- package/test/dist/suspense.test.d.ts.map +1 -0
- package/test/form-validation-errors.test.ts +2 -1
- package/test/form.test.ts +2 -1
- package/test/inactiveInvalidation.test.ts +172 -0
- package/test/interrupt-refetch-repro.test.ts +625 -0
- package/test/lib.test.ts +240 -0
- package/test/liveQueryInvalidation.test.ts +108 -0
- package/test/makeClient.test.ts +414 -32
- package/test/query-span.test.ts +92 -0
- package/test/queryOptions.test.ts +88 -0
- package/test/streamFinal.test.ts +64 -0
- package/test/streamFn.test.ts +457 -0
- package/test/stubs.ts +194 -36
- package/test/suspense-regression.test.ts +168 -0
- package/test/suspense.test.ts +160 -0
- package/tsconfig.examples.json +20 -0
- package/tsconfig.json +9 -1
- package/tsconfig.src.json +34 -34
- package/tsconfig.test.json +2 -2
- package/vitest.config.ts +5 -5
- package/eslint.config.mjs +0 -24
- 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
|
+
})
|