@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
package/test/stubs.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { type MessageFormatElement } from "@formatjs/icu-messageformat-parser"
|
|
3
3
|
import * as Intl from "@formatjs/intl"
|
|
4
|
-
import { Effect, Layer, ManagedRuntime, Option, S } from "effect-app"
|
|
5
4
|
import { ApiClientFactory, makeRpcClient } from "effect-app/client"
|
|
5
|
+
import * as Effect from "effect-app/Effect"
|
|
6
|
+
import * as Layer from "effect-app/Layer"
|
|
7
|
+
import * as Option from "effect-app/Option"
|
|
6
8
|
import { RpcContextMap } from "effect-app/rpc"
|
|
7
|
-
import * as
|
|
8
|
-
import
|
|
9
|
+
import * as S from "effect-app/Schema"
|
|
10
|
+
import * as Exit from "effect/Exit"
|
|
11
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient"
|
|
12
|
+
import * as ManagedRuntime from "effect/ManagedRuntime"
|
|
13
|
+
import { createApp, ref } from "vue"
|
|
9
14
|
import { Commander } from "../src/commander.js"
|
|
10
15
|
import { I18n } from "../src/intl.js"
|
|
11
16
|
import { makeClient } from "../src/makeClient.js"
|
|
@@ -26,33 +31,40 @@ const fakeToastLayer = (toasts: any[] = []) =>
|
|
|
26
31
|
toasts.splice(idx, 1)
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
const scheduleAutoDismiss = (toast: any, timeout: number | undefined) => {
|
|
35
|
+
// Treat Infinity / undefined as "stays until explicitly replaced/dismissed".
|
|
36
|
+
// Node's setTimeout silently clamps Infinity to 1ms which would otherwise
|
|
37
|
+
// cause the toast to disappear before assertions can observe it.
|
|
38
|
+
if (timeout === undefined || !Number.isFinite(timeout)) return
|
|
39
|
+
toast.timeoutId = setTimeout(() => {
|
|
40
|
+
const i = toasts.indexOf(toast)
|
|
41
|
+
if (i > -1) toasts.splice(i, 1)
|
|
42
|
+
}, timeout)
|
|
43
|
+
}
|
|
44
|
+
const fakeToast =
|
|
45
|
+
(type: "error" | "warning" | "success" | "info") => (message: string, options?: Toast.ToastOpts) => {
|
|
46
|
+
const id = options?.id ?? Math.random().toString(36).substring(2, 15)
|
|
47
|
+
console.log(`Toast [${type}][${id}]: ${message}`, options)
|
|
32
48
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}, options?.timeout ?? 3000)
|
|
47
|
-
toasts.push(toast)
|
|
49
|
+
options = { ...options, id }
|
|
50
|
+
const idx = toasts.findIndex((_) => _.id === id)
|
|
51
|
+
if (idx > -1) {
|
|
52
|
+
const toast = toasts[idx]
|
|
53
|
+
clearTimeout(toast.timeoutId)
|
|
54
|
+
Object.assign(toast, { type, message, options })
|
|
55
|
+
scheduleAutoDismiss(toast, options?.timeout ?? 3000)
|
|
56
|
+
} else {
|
|
57
|
+
const toast: any = { id, type, message, options }
|
|
58
|
+
toasts.push(toast)
|
|
59
|
+
scheduleAutoDismiss(toast, options?.timeout ?? 3000)
|
|
60
|
+
}
|
|
61
|
+
return id
|
|
48
62
|
}
|
|
49
|
-
return id
|
|
50
|
-
}
|
|
51
63
|
return Toast.Toast.of(Toast.wrap({
|
|
52
|
-
error: fakeToast,
|
|
53
|
-
warning: fakeToast,
|
|
54
|
-
success: fakeToast,
|
|
55
|
-
info: fakeToast,
|
|
64
|
+
error: fakeToast("error"),
|
|
65
|
+
warning: fakeToast("warning"),
|
|
66
|
+
success: fakeToast("success"),
|
|
67
|
+
info: fakeToast("info"),
|
|
56
68
|
dismiss
|
|
57
69
|
})) as any
|
|
58
70
|
})
|
|
@@ -91,41 +103,166 @@ export const useExperimental = (
|
|
|
91
103
|
return Effect.runSync(makeUseCommand<WithToast | Toast.Toast | I18n>(Layer.empty).pipe(Effect.provide(layers)))
|
|
92
104
|
}
|
|
93
105
|
|
|
106
|
+
// Effect-returning variant: keeps the caller's runtime context (e.g. a TestClock
|
|
107
|
+
// provided by `it.effect`) so virtual-time advances reach the runtime captured
|
|
108
|
+
// inside Commander.
|
|
109
|
+
export const useExperimentalE = (
|
|
110
|
+
options?: { messages?: Record<string, string> | Record<string, MessageFormatElement[]>; toasts: any[] }
|
|
111
|
+
) => {
|
|
112
|
+
const FakeIntlLayer = fakeIntlLayer(options?.messages)
|
|
113
|
+
const FakeToastLayer = fakeToastLayer(options?.toasts)
|
|
114
|
+
const CommanderLayer = Commander.Default.pipe(Layer.provide([FakeIntlLayer, FakeToastLayer]))
|
|
115
|
+
const WithToastLayer = WithToast.Default.pipe(Layer.provide(FakeToastLayer))
|
|
116
|
+
const layers = Layer.mergeAll(CommanderLayer, WithToastLayer, FakeToastLayer, FakeIntlLayer)
|
|
117
|
+
|
|
118
|
+
return makeUseCommand<WithToast | Toast.Toast | I18n>(Layer.empty).pipe(Effect.provide(layers))
|
|
119
|
+
}
|
|
120
|
+
|
|
94
121
|
export class RequestContextMap extends RpcContextMap.makeMap({}) {}
|
|
95
|
-
|
|
122
|
+
const stubMiddleware = {
|
|
123
|
+
requestContextMap: RequestContextMap.config,
|
|
124
|
+
requestContext: undefined as never
|
|
125
|
+
}
|
|
126
|
+
export const { TaggedRequestFor } = makeRpcClient(stubMiddleware)
|
|
96
127
|
|
|
97
128
|
export const SomethingReq = TaggedRequestFor("Something")
|
|
129
|
+
const SomethingQuery = SomethingReq.Query
|
|
130
|
+
const SomethingCommand = SomethingReq.Command
|
|
98
131
|
|
|
99
|
-
class SomethingGetSomething2 extends
|
|
132
|
+
class SomethingGetSomething2 extends SomethingQuery<SomethingGetSomething2>()("GetSomething2", {
|
|
100
133
|
id: S.String
|
|
101
134
|
}, { success: S.FiniteFromString }) {}
|
|
102
135
|
|
|
136
|
+
class SomethingGetSomething3 extends SomethingQuery<SomethingGetSomething3>()("GetSomething3", {
|
|
137
|
+
id: S.NullOr(S.String).withConstructorDefault
|
|
138
|
+
}, { success: S.FiniteFromString }) {}
|
|
139
|
+
|
|
140
|
+
class SomethingGetSomething4
|
|
141
|
+
extends SomethingQuery<SomethingGetSomething4>()("GetSomething4", {}, { success: S.FiniteFromString })
|
|
142
|
+
{}
|
|
143
|
+
|
|
103
144
|
class SomethingGetSomething2WithDependencies
|
|
104
|
-
extends
|
|
145
|
+
extends SomethingQuery<SomethingGetSomething2WithDependencies>()("GetSomething2", {
|
|
105
146
|
id: S.String
|
|
106
147
|
}, {
|
|
107
148
|
// this is intentilally fake, to simulate a codec that requires a dependency
|
|
108
|
-
success: S.FiniteFromString
|
|
149
|
+
success: S.FiniteFromString,
|
|
109
150
|
error: S.String
|
|
110
151
|
})
|
|
111
152
|
{}
|
|
112
153
|
|
|
154
|
+
type SomethingInvalidationResources = {
|
|
155
|
+
GetSomething2: typeof SomethingGetSomething2
|
|
156
|
+
GetSomething2WithDependencies: typeof SomethingGetSomething2WithDependencies
|
|
157
|
+
GetSomething3: typeof SomethingGetSomething3
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// command stubs covering the input-shape matrix
|
|
161
|
+
class SomethingDoNoProps extends SomethingCommand<SomethingDoNoProps>()("DoNoProps", {}) {}
|
|
162
|
+
|
|
163
|
+
class SomethingDoOptionalOnly extends SomethingCommand<SomethingDoOptionalOnly>()("DoOptionalOnly", {
|
|
164
|
+
name: S.optional(S.String)
|
|
165
|
+
}) {}
|
|
166
|
+
|
|
167
|
+
class SomethingDoRequiredOnly extends SomethingCommand<SomethingDoRequiredOnly>()("DoRequiredOnly", {
|
|
168
|
+
id: S.String
|
|
169
|
+
}) {}
|
|
170
|
+
|
|
171
|
+
class SomethingDoMixed extends SomethingCommand<SomethingDoMixed>()("DoMixed", {
|
|
172
|
+
id: S.String,
|
|
173
|
+
name: S.optional(S.String)
|
|
174
|
+
}) {}
|
|
175
|
+
|
|
176
|
+
class SomethingDoSomething extends SomethingCommand<
|
|
177
|
+
SomethingDoSomething,
|
|
178
|
+
{ Something: SomethingInvalidationResources }
|
|
179
|
+
>()("DoSomething", {
|
|
180
|
+
id: S.String
|
|
181
|
+
}, {
|
|
182
|
+
success: S.FiniteFromString
|
|
183
|
+
}, (queryKey, { Something }, input, output) => {
|
|
184
|
+
return [
|
|
185
|
+
{ filters: { queryKey } },
|
|
186
|
+
{
|
|
187
|
+
filters: {
|
|
188
|
+
queryKey: [
|
|
189
|
+
Something["GetSomething2"].id,
|
|
190
|
+
input.id,
|
|
191
|
+
Exit.isSuccess(output) ? output.value.toString() : "failed"
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
}) {}
|
|
197
|
+
|
|
198
|
+
// success schema has encoded shape { a: string | null } — used to test projection constraints
|
|
199
|
+
class SomethingGetStructNullable extends SomethingQuery<SomethingGetStructNullable>()("GetStructNullable", {}, {
|
|
200
|
+
success: S.Struct({ a: S.NullOr(S.String) })
|
|
201
|
+
}) {}
|
|
202
|
+
|
|
203
|
+
class SomethingStreamQuery extends SomethingQuery<SomethingStreamQuery>()("StreamQuery", {
|
|
204
|
+
id: S.String
|
|
205
|
+
}, {
|
|
206
|
+
stream: true,
|
|
207
|
+
success: S.FiniteFromString
|
|
208
|
+
}) {}
|
|
209
|
+
|
|
210
|
+
/** Stream event: intermediate progress update. */
|
|
211
|
+
export class OperationProgress extends S.TaggedClass<OperationProgress>()("OperationProgress", {
|
|
212
|
+
completed: S.NonNegativeInt,
|
|
213
|
+
total: S.NonNegativeInt
|
|
214
|
+
}) {}
|
|
215
|
+
|
|
216
|
+
/** Stream event: final completion result. */
|
|
217
|
+
export class ExportComplete extends S.TaggedClass<ExportComplete>()("ExportComplete", {
|
|
218
|
+
fileUrl: S.NonEmptyString
|
|
219
|
+
}) {}
|
|
220
|
+
|
|
221
|
+
/** Stream with no `final` schema — execute resolves with `void`. */
|
|
222
|
+
class SomethingStreamWithoutFinal extends SomethingCommand<SomethingStreamWithoutFinal>()("StreamWithoutFinal", {
|
|
223
|
+
id: S.String
|
|
224
|
+
}, {
|
|
225
|
+
stream: true,
|
|
226
|
+
success: S.Union([OperationProgress, ExportComplete])
|
|
227
|
+
}) {}
|
|
228
|
+
|
|
229
|
+
/** Stream with a `final` schema — execute resolves with `ExportComplete`. */
|
|
230
|
+
class SomethingStreamWithFinal extends SomethingCommand<SomethingStreamWithFinal>()("StreamWithFinal", {
|
|
231
|
+
id: S.String
|
|
232
|
+
}, {
|
|
233
|
+
stream: true,
|
|
234
|
+
success: S.Union([OperationProgress, ExportComplete]),
|
|
235
|
+
final: ExportComplete
|
|
236
|
+
}) {}
|
|
237
|
+
|
|
113
238
|
export const Something = {
|
|
114
239
|
GetSomething2: SomethingGetSomething2,
|
|
115
|
-
GetSomething2WithDependencies: SomethingGetSomething2WithDependencies
|
|
240
|
+
GetSomething2WithDependencies: SomethingGetSomething2WithDependencies,
|
|
241
|
+
GetSomething3: SomethingGetSomething3,
|
|
242
|
+
GetSomething4: SomethingGetSomething4,
|
|
243
|
+
DoNoProps: SomethingDoNoProps,
|
|
244
|
+
DoOptionalOnly: SomethingDoOptionalOnly,
|
|
245
|
+
DoRequiredOnly: SomethingDoRequiredOnly,
|
|
246
|
+
DoMixed: SomethingDoMixed,
|
|
247
|
+
DoSomething: SomethingDoSomething,
|
|
248
|
+
GetStructNullable: SomethingGetStructNullable,
|
|
249
|
+
StreamQuery: SomethingStreamQuery,
|
|
250
|
+
StreamWithoutFinal: SomethingStreamWithoutFinal,
|
|
251
|
+
StreamWithFinal: SomethingStreamWithFinal
|
|
116
252
|
}
|
|
117
253
|
|
|
118
254
|
export const SomethingElseReq = TaggedRequestFor("SomethingElse")
|
|
255
|
+
const SomethingElseQuery = SomethingElseReq.Query
|
|
119
256
|
|
|
120
|
-
class SomethingElseGetSomething2 extends
|
|
257
|
+
class SomethingElseGetSomething2 extends SomethingElseQuery<SomethingElseGetSomething2>()("GetSomething2", {
|
|
121
258
|
id: S.String
|
|
122
259
|
}, { success: S.FiniteFromString }) {}
|
|
123
260
|
|
|
124
261
|
class SomethingElseGetSomething2WithDependencies
|
|
125
|
-
extends
|
|
262
|
+
extends SomethingElseQuery<SomethingElseGetSomething2WithDependencies>()("GetSomething2", {
|
|
126
263
|
id: S.String
|
|
127
264
|
}, {
|
|
128
|
-
success: S.FiniteFromString
|
|
265
|
+
success: S.FiniteFromString,
|
|
129
266
|
error: S.String
|
|
130
267
|
})
|
|
131
268
|
{}
|
|
@@ -148,5 +285,26 @@ export const useClient = (
|
|
|
148
285
|
const layers = Layer.mergeAll(CommanderLayer, WithToastLayer, FakeToastLayer, FakeIntlLayer, api)
|
|
149
286
|
|
|
150
287
|
const clientFor_ = ApiClientFactory.makeFor(Layer.empty)
|
|
151
|
-
|
|
288
|
+
const rawClient = makeClient(() => ManagedRuntime.make(layers), clientFor_, Layer.empty)
|
|
289
|
+
|
|
290
|
+
// Provide a Vue injection context so composition-API hooks called during client
|
|
291
|
+
// initialisation work outside a component setup() function.
|
|
292
|
+
const vueApp = createApp({})
|
|
293
|
+
|
|
294
|
+
const origClientFor = rawClient.clientFor
|
|
295
|
+
const clientFor: typeof origClientFor = function(m, ...args) {
|
|
296
|
+
const proxy = origClientFor(m, ...args)
|
|
297
|
+
// Warm up lazy mutation-hook initialisation inside the Vue injection context.
|
|
298
|
+
// After the first property access, useMutation() is cached and subsequent
|
|
299
|
+
// accesses outside the context succeed.
|
|
300
|
+
const firstPropertyName = Object.keys(m)[0]
|
|
301
|
+
if (firstPropertyName !== undefined) {
|
|
302
|
+
vueApp.runWithContext(() => {
|
|
303
|
+
void (proxy as Record<string, unknown>)[firstPropertyName]
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
return proxy
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { ...rawClient, clientFor }
|
|
152
310
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { defaultRegistry, registryKey } from "@effect/atom-vue"
|
|
3
|
+
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query"
|
|
4
|
+
import * as Context from "effect-app/Context"
|
|
5
|
+
import * as Effect from "effect-app/Effect"
|
|
6
|
+
import * as Option from "effect-app/Option"
|
|
7
|
+
import * as Layer from "effect/Layer"
|
|
8
|
+
import * as ManagedRuntime from "effect/ManagedRuntime"
|
|
9
|
+
import * as AsyncResult from "effect/reactivity/AsyncResult"
|
|
10
|
+
import { createApp, effectScope, nextTick, ref } from "vue"
|
|
11
|
+
import { makeAtomClientRuntime } from "../src/atomQuery.js"
|
|
12
|
+
import { makeTanstackQuery } from "../src/internal/tanstackQuery.js"
|
|
13
|
+
import { QueryImpl } from "../src/makeClient.js"
|
|
14
|
+
|
|
15
|
+
const fakeHandler = (id: string, run: (input: any) => Effect.Effect<any, any, never>) => ({ id, handler: run }) as any
|
|
16
|
+
|
|
17
|
+
function makeContext(queryClient: QueryClient) {
|
|
18
|
+
const app = createApp({ render: () => null })
|
|
19
|
+
app.use(VueQueryPlugin, { queryClient })
|
|
20
|
+
const scope = effectScope(true)
|
|
21
|
+
const run = <T>(fn: () => T): T => {
|
|
22
|
+
let out!: T
|
|
23
|
+
app.runWithContext(() => {
|
|
24
|
+
scope.run(() => {
|
|
25
|
+
out = fn()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
return { run, dispose: () => scope.stop() }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it("makeClient .suspense(): observer re-pointed mid-flight -> resolves (seeded) instead of dying", async () => {
|
|
34
|
+
const queryClient = new QueryClient({
|
|
35
|
+
defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: 0 } }
|
|
36
|
+
})
|
|
37
|
+
const getRuntime = () => Context.empty()
|
|
38
|
+
const qi = new QueryImpl(
|
|
39
|
+
getRuntime,
|
|
40
|
+
() => {
|
|
41
|
+
throw new Error("atom runtime is unused by this legacy TanStack regression")
|
|
42
|
+
},
|
|
43
|
+
makeTanstackQuery(getRuntime, queryClient)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// key "a" resolves via this deferred; any other key hangs forever.
|
|
47
|
+
let resolveA!: (v: string) => void
|
|
48
|
+
const aData = new Promise<string>((res) => {
|
|
49
|
+
resolveA = res
|
|
50
|
+
})
|
|
51
|
+
const handler = fakeHandler(
|
|
52
|
+
"Test/KeyRace",
|
|
53
|
+
(input: any) => Effect.promise(() => (input.id === "a" ? aData : new Promise<string>(() => {})))
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const ctx = makeContext(queryClient)
|
|
57
|
+
|
|
58
|
+
// Keep-alive observer pins key "a" so flipping the suspense arg below does NOT cancel "a".
|
|
59
|
+
ctx.run(() => qi.useQuery(handler)(ref({ id: "a" }) as any, {} as any))
|
|
60
|
+
await nextTick()
|
|
61
|
+
|
|
62
|
+
// Suspense on key "a" (shares the in-flight "a" query -> no extra handler call).
|
|
63
|
+
const suspenseId = ref<{ id: string }>({ id: "a" })
|
|
64
|
+
const suspense = qi.useSuspenseQuery(handler)
|
|
65
|
+
const promise = ctx.run(() => suspense(suspenseId as any, {} as any))
|
|
66
|
+
|
|
67
|
+
// Re-point the suspense observer to "b" (pending). "a" still has the keep-alive observer.
|
|
68
|
+
suspenseId.value = { id: "b" }
|
|
69
|
+
await nextTick()
|
|
70
|
+
await nextTick()
|
|
71
|
+
|
|
72
|
+
// Resolve "a": suspense() resolves with DATA_A while resultRef is parked on the pending "b".
|
|
73
|
+
resolveA("DATA_A")
|
|
74
|
+
|
|
75
|
+
// BEFORE fix: rejects (die). AFTER fix: resolves, seeded from the suspense value.
|
|
76
|
+
const [resultRef, latestRef] = (await promise) as any
|
|
77
|
+
|
|
78
|
+
expect(AsyncResult.isSuccess(resultRef.value)).toBe(true)
|
|
79
|
+
expect(Option.getOrUndefined(AsyncResult.value(resultRef.value))).toBe("DATA_A")
|
|
80
|
+
expect(latestRef.value).toBe("DATA_A")
|
|
81
|
+
|
|
82
|
+
ctx.dispose()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Prod crash (Sentry "Internal Error: suspense resolved without a latest value"): a reactive arg
|
|
86
|
+
// re-points the suspense view at a DIFFERENT family atom. The new atom starts Initial (no
|
|
87
|
+
// previousSuccess carried over), so the always-defined `latest` computed found undefined and threw
|
|
88
|
+
// during the next render flush. The suspense contract is kept by serving the last defined value
|
|
89
|
+
// while the new atom fetches.
|
|
90
|
+
it("atom engine .suspense(): reactive arg switched to an uncached input serves the previous value", async () => {
|
|
91
|
+
defaultRegistry.reset()
|
|
92
|
+
const mrt = ManagedRuntime.make(Layer.empty)
|
|
93
|
+
const baseContext = mrt.runSync(Effect.context<never>())
|
|
94
|
+
const rt = makeAtomClientRuntime(
|
|
95
|
+
() => Layer.succeedContext(baseContext) as Layer.Layer<any, never, never>,
|
|
96
|
+
mrt.memoMap
|
|
97
|
+
)
|
|
98
|
+
const qi = new QueryImpl(() => Context.empty(), () => rt)
|
|
99
|
+
|
|
100
|
+
// "a" resolves immediately; any other input hangs forever (stays Initial+waiting).
|
|
101
|
+
const handler = fakeHandler(
|
|
102
|
+
"TestSuspense/ReactiveArg",
|
|
103
|
+
(input: any) => (input.id === "a" ? Effect.succeed("DATA_A") : Effect.never)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const argRef = ref({ id: "a" })
|
|
107
|
+
let promise: Promise<any> | undefined
|
|
108
|
+
const host = document.createElement("div")
|
|
109
|
+
const app = createApp({
|
|
110
|
+
setup() {
|
|
111
|
+
promise = qi.useSuspenseQuery(handler)(argRef as any, {} as any)
|
|
112
|
+
return () => null
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
app.provide(registryKey, defaultRegistry)
|
|
116
|
+
app.mount(host)
|
|
117
|
+
|
|
118
|
+
const [resultRef, latestRef] = await promise
|
|
119
|
+
expect(latestRef.value).toBe("DATA_A")
|
|
120
|
+
|
|
121
|
+
// Re-point to an input whose atom has never fetched -> Initial, no previousSuccess.
|
|
122
|
+
argRef.value = { id: "b" }
|
|
123
|
+
await nextTick()
|
|
124
|
+
|
|
125
|
+
// BEFORE fix: throws "Internal Error: suspense resolved without a latest value".
|
|
126
|
+
expect(latestRef.value).toBe("DATA_A")
|
|
127
|
+
// The result ref itself does reflect the in-flight fetch for the new input.
|
|
128
|
+
expect(AsyncResult.isInitial(resultRef.value)).toBe(true)
|
|
129
|
+
|
|
130
|
+
app.unmount()
|
|
131
|
+
defaultRegistry.reset()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("makeTanstackQuery structurally shares Effect-Equal leaves by default", async () => {
|
|
135
|
+
const queryClient = new QueryClient({
|
|
136
|
+
defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: 0 } }
|
|
137
|
+
})
|
|
138
|
+
const getRuntime = () => Context.empty()
|
|
139
|
+
let calls = 0
|
|
140
|
+
const handler = fakeHandler(
|
|
141
|
+
"Test/StructuralSharing",
|
|
142
|
+
() =>
|
|
143
|
+
Effect.sync(() => {
|
|
144
|
+
calls += 1
|
|
145
|
+
return {
|
|
146
|
+
stable: {
|
|
147
|
+
date: new Date("2026-01-01T00:00:00.000Z"),
|
|
148
|
+
option: Option.some({ id: "same" })
|
|
149
|
+
},
|
|
150
|
+
revision: calls
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
)
|
|
154
|
+
const query = makeTanstackQuery(getRuntime, queryClient)(handler)
|
|
155
|
+
const ctx = makeContext(queryClient)
|
|
156
|
+
|
|
157
|
+
const [, , , handle] = ctx.run(() => query(undefined, {}))
|
|
158
|
+
const first = await Effect.runPromise(handle.awaitResult())
|
|
159
|
+
const second = await Effect.runPromise(handle.refetch())
|
|
160
|
+
|
|
161
|
+
expect(second.revision).toBe(2)
|
|
162
|
+
expect(second).not.toBe(first)
|
|
163
|
+
expect(second.stable).toBe(first.stable)
|
|
164
|
+
expect(second.stable.date).toBe(first.stable.date)
|
|
165
|
+
expect(second.stable.option).toBe(first.stable.option)
|
|
166
|
+
|
|
167
|
+
ctx.dispose()
|
|
168
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { defaultRegistry, registryKey } from "@effect/atom-vue"
|
|
2
|
+
import { Effect, Option } from "effect-app"
|
|
3
|
+
import * as AsyncResult from "effect/reactivity/AsyncResult"
|
|
4
|
+
import * as Atom from "effect/reactivity/Atom"
|
|
5
|
+
import { computed, createApp, nextTick, ref } from "vue"
|
|
6
|
+
import { withQueryOptions } from "../src/atomQuery.js"
|
|
7
|
+
import { useAtomQuery, useAtomSuspense } from "../src/query.js"
|
|
8
|
+
import { awaitResolvedSuspenseResult } from "../src/suspense.js"
|
|
9
|
+
|
|
10
|
+
const listenerCount = (atom: Atom.Atom<unknown>) => defaultRegistry.getNodes().get(atom)?.listeners.size ?? 0
|
|
11
|
+
|
|
12
|
+
it("waits for the query result ref after suspense resolves", async () => {
|
|
13
|
+
const result = ref<AsyncResult.AsyncResult<number, never>>(AsyncResult.initial(true))
|
|
14
|
+
const promise = Effect.runPromise(awaitResolvedSuspenseResult(computed(() => result.value)))
|
|
15
|
+
|
|
16
|
+
result.value = AsyncResult.success(123)
|
|
17
|
+
|
|
18
|
+
expect(Option.getOrUndefined(AsyncResult.value(await promise))).toBe(123)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("keeps unresolved query results initial", async () => {
|
|
22
|
+
const result = ref<AsyncResult.AsyncResult<number, never>>(AsyncResult.initial(true))
|
|
23
|
+
const settled = await Effect.runPromise(awaitResolvedSuspenseResult(computed(() => result.value)))
|
|
24
|
+
|
|
25
|
+
expect(AsyncResult.isInitial(settled)).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("stops atom suspense subscriptions when the setup scope is disposed", async () => {
|
|
29
|
+
defaultRegistry.reset()
|
|
30
|
+
const atom = Atom.make(Effect.succeed(123))
|
|
31
|
+
let promise: ReturnType<typeof useAtomSuspense<number, never>> | undefined
|
|
32
|
+
const host = document.createElement("div")
|
|
33
|
+
const app = createApp({
|
|
34
|
+
setup() {
|
|
35
|
+
promise = useAtomSuspense(() => atom)
|
|
36
|
+
return () => null
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
app.provide(registryKey, defaultRegistry)
|
|
40
|
+
app.mount(host)
|
|
41
|
+
|
|
42
|
+
if (promise === undefined) {
|
|
43
|
+
throw new Error("suspense setup did not initialize")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await promise
|
|
47
|
+
expect(listenerCount(atom)).toBe(1)
|
|
48
|
+
|
|
49
|
+
app.unmount()
|
|
50
|
+
await nextTick()
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
52
|
+
expect(listenerCount(atom)).toBe(0)
|
|
53
|
+
defaultRegistry.reset()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("does not keep query option wrapper subscriptions after unmount", async () => {
|
|
57
|
+
defaultRegistry.reset()
|
|
58
|
+
const atom = Atom.make(Effect.succeed(123))
|
|
59
|
+
const observed = withQueryOptions(atom)
|
|
60
|
+
let promise: ReturnType<typeof useAtomSuspense<number, never>> | undefined
|
|
61
|
+
const host = document.createElement("div")
|
|
62
|
+
const app = createApp({
|
|
63
|
+
setup() {
|
|
64
|
+
promise = useAtomSuspense(() => observed)
|
|
65
|
+
return () => null
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
app.provide(registryKey, defaultRegistry)
|
|
69
|
+
app.mount(host)
|
|
70
|
+
|
|
71
|
+
if (promise === undefined) {
|
|
72
|
+
throw new Error("suspense setup did not initialize")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await promise
|
|
76
|
+
expect(listenerCount(atom)).toBe(1)
|
|
77
|
+
|
|
78
|
+
app.unmount()
|
|
79
|
+
await nextTick()
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
81
|
+
expect(listenerCount(atom)).toBe(0)
|
|
82
|
+
defaultRegistry.reset()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("aborts atom suspense promises when the component unmounts", async () => {
|
|
86
|
+
defaultRegistry.reset()
|
|
87
|
+
const atom = Atom.make(Effect.never)
|
|
88
|
+
let promise: ReturnType<typeof useAtomSuspense<number, never>> | undefined
|
|
89
|
+
const host = document.createElement("div")
|
|
90
|
+
const app = createApp({
|
|
91
|
+
setup() {
|
|
92
|
+
promise = useAtomSuspense(() => atom)
|
|
93
|
+
return () => null
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
app.provide(registryKey, defaultRegistry)
|
|
97
|
+
app.mount(host)
|
|
98
|
+
|
|
99
|
+
if (promise === undefined) {
|
|
100
|
+
throw new Error("suspense setup did not initialize")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
app.unmount()
|
|
104
|
+
await nextTick()
|
|
105
|
+
const settled = await Promise.race([
|
|
106
|
+
promise.then(() => "resolved" as const, () => "rejected" as const),
|
|
107
|
+
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20))
|
|
108
|
+
])
|
|
109
|
+
expect(settled).toBe("rejected")
|
|
110
|
+
defaultRegistry.reset()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("stops atom query subscriptions when the component unmounts", async () => {
|
|
114
|
+
defaultRegistry.reset()
|
|
115
|
+
const atom = Atom.make(Effect.succeed(123))
|
|
116
|
+
const host = document.createElement("div")
|
|
117
|
+
const app = createApp({
|
|
118
|
+
setup() {
|
|
119
|
+
useAtomQuery(() => atom)
|
|
120
|
+
return () => null
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
app.provide(registryKey, defaultRegistry)
|
|
124
|
+
app.mount(host)
|
|
125
|
+
|
|
126
|
+
await nextTick()
|
|
127
|
+
expect(listenerCount(atom)).toBe(1)
|
|
128
|
+
|
|
129
|
+
app.unmount()
|
|
130
|
+
await nextTick()
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
132
|
+
expect(listenerCount(atom)).toBe(0)
|
|
133
|
+
defaultRegistry.reset()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("does not keep non-suspense query option wrapper subscriptions after unmount", async () => {
|
|
137
|
+
defaultRegistry.reset()
|
|
138
|
+
const atom = Atom.make(Effect.succeed(123))
|
|
139
|
+
const observed = withQueryOptions(atom)
|
|
140
|
+
const host = document.createElement("div")
|
|
141
|
+
const app = createApp({
|
|
142
|
+
setup() {
|
|
143
|
+
useAtomQuery(() => observed)
|
|
144
|
+
return () => null
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
app.provide(registryKey, defaultRegistry)
|
|
148
|
+
app.mount(host)
|
|
149
|
+
|
|
150
|
+
await nextTick()
|
|
151
|
+
expect(listenerCount(observed)).toBe(1)
|
|
152
|
+
expect(listenerCount(atom)).toBe(1)
|
|
153
|
+
|
|
154
|
+
app.unmount()
|
|
155
|
+
await nextTick()
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
157
|
+
expect(listenerCount(observed)).toBe(0)
|
|
158
|
+
expect(listenerCount(atom)).toBe(0)
|
|
159
|
+
defaultRegistry.reset()
|
|
160
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"include": [
|
|
4
|
+
"examples"
|
|
5
|
+
],
|
|
6
|
+
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "./tsconfig.src.json"
|
|
9
|
+
}
|
|
10
|
+
],
|
|
11
|
+
"compilerOptions": {
|
|
12
|
+
"lib": [
|
|
13
|
+
"esnext",
|
|
14
|
+
"DOM"
|
|
15
|
+
],
|
|
16
|
+
"tsBuildInfoFile": ".tsbuildinfo/examples.tsbuildinfo",
|
|
17
|
+
"rootDir": "examples",
|
|
18
|
+
"moduleResolution": "Node16"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
"isolatedModules": true,
|
|
15
15
|
"esModuleInterop": true,
|
|
16
16
|
"skipLibCheck": true,
|
|
17
|
-
"plugins": [
|
|
17
|
+
"plugins": [
|
|
18
|
+
{
|
|
19
|
+
"name": "@effect/language-service",
|
|
20
|
+
"diagnostics": false
|
|
21
|
+
}
|
|
22
|
+
],
|
|
18
23
|
"module": "Node16",
|
|
19
24
|
"lib": [
|
|
20
25
|
"ES2023"
|
|
@@ -34,6 +39,9 @@
|
|
|
34
39
|
"moduleResolution": "Node16",
|
|
35
40
|
"noErrorTruncation": true,
|
|
36
41
|
"forceConsistentCasingInFileNames": true,
|
|
42
|
+
"erasableSyntaxOnly": true,
|
|
43
|
+
"rewriteRelativeImportExtensions": true,
|
|
44
|
+
"allowImportingTsExtensions": true,
|
|
37
45
|
"types": [
|
|
38
46
|
"node"
|
|
39
47
|
]
|