@effect-app/vue 4.0.0-beta.33 → 4.0.0-beta.330

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 (148) hide show
  1. package/CHANGELOG.md +2457 -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 +634 -0
  6. package/dist/commander.d.ts.map +1 -0
  7. package/dist/commander.js +1068 -0
  8. package/dist/{experimental/confirm.d.ts → confirm.d.ts} +6 -4
  9. package/dist/confirm.d.ts.map +1 -0
  10. package/dist/confirm.js +26 -0
  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 +42 -16
  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 +15 -0
  24. package/dist/intl.d.ts.map +1 -0
  25. package/dist/intl.js +9 -0
  26. package/dist/lib.d.ts +7 -9
  27. package/dist/lib.d.ts.map +1 -1
  28. package/dist/lib.js +35 -10
  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 +236 -336
  33. package/dist/makeClient.d.ts.map +1 -1
  34. package/dist/makeClient.js +417 -408
  35. package/dist/makeContext.d.ts.map +1 -1
  36. package/dist/makeIntl.d.ts.map +1 -1
  37. package/dist/{experimental/makeUseCommand.d.ts → makeUseCommand.d.ts} +4 -3
  38. package/dist/makeUseCommand.d.ts.map +1 -0
  39. package/dist/makeUseCommand.js +13 -0
  40. package/dist/mutate.d.ts +109 -40
  41. package/dist/mutate.d.ts.map +1 -1
  42. package/dist/mutate.js +235 -58
  43. package/dist/query.d.ts +158 -45
  44. package/dist/query.d.ts.map +1 -1
  45. package/dist/query.js +380 -122
  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 -14
  53. package/dist/runtime.d.ts.map +1 -1
  54. package/dist/runtime.js +2 -26
  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 +2 -0
  59. package/dist/toast.d.ts.map +1 -0
  60. package/dist/toast.js +2 -0
  61. package/dist/withToast.d.ts +2 -0
  62. package/dist/withToast.d.ts.map +1 -0
  63. package/dist/withToast.js +2 -0
  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 +36 -95
  69. package/src/atomQuery.ts +600 -0
  70. package/src/commander.ts +3408 -0
  71. package/src/{experimental/confirm.ts → confirm.ts} +13 -15
  72. package/src/dependencyMetadata.ts +84 -0
  73. package/src/errorReporter.ts +65 -75
  74. package/src/form.ts +57 -21
  75. package/src/index.ts +7 -7
  76. package/src/internal/tanstackQuery.ts +308 -0
  77. package/src/intl.ts +12 -0
  78. package/src/lib.ts +49 -21
  79. package/src/liveQueryInvalidation.ts +138 -0
  80. package/src/makeClient.ts +971 -1159
  81. package/src/makeIntl.ts +2 -2
  82. package/src/{experimental/makeUseCommand.ts → makeUseCommand.ts} +9 -6
  83. package/src/mutate.ts +406 -153
  84. package/src/query.ts +763 -260
  85. package/src/queryLifetime.ts +74 -0
  86. package/src/routeParams.ts +7 -7
  87. package/src/runtime.ts +1 -31
  88. package/src/suspense.ts +43 -0
  89. package/src/toast.ts +1 -0
  90. package/src/withToast.ts +1 -0
  91. package/test/Mutation.test.ts +181 -24
  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 +3751 -120
  104. package/test/dist/stubs.d.ts.map +1 -1
  105. package/test/dist/stubs.js +190 -30
  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 +24 -19
  109. package/test/form.test.ts +25 -6
  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 +437 -38
  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 +227 -42
  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 -2
  124. package/tsconfig.src.json +34 -34
  125. package/tsconfig.test.json +2 -2
  126. package/vitest.config.ts +5 -5
  127. package/dist/experimental/commander.d.ts +0 -359
  128. package/dist/experimental/commander.d.ts.map +0 -1
  129. package/dist/experimental/commander.js +0 -557
  130. package/dist/experimental/confirm.d.ts.map +0 -1
  131. package/dist/experimental/confirm.js +0 -28
  132. package/dist/experimental/intl.d.ts +0 -16
  133. package/dist/experimental/intl.d.ts.map +0 -1
  134. package/dist/experimental/intl.js +0 -5
  135. package/dist/experimental/makeUseCommand.d.ts.map +0 -1
  136. package/dist/experimental/makeUseCommand.js +0 -13
  137. package/dist/experimental/toast.d.ts +0 -47
  138. package/dist/experimental/toast.d.ts.map +0 -1
  139. package/dist/experimental/toast.js +0 -41
  140. package/dist/experimental/withToast.d.ts +0 -25
  141. package/dist/experimental/withToast.d.ts.map +0 -1
  142. package/dist/experimental/withToast.js +0 -45
  143. package/eslint.config.mjs +0 -24
  144. package/src/experimental/commander.ts +0 -1835
  145. package/src/experimental/intl.ts +0 -9
  146. package/src/experimental/toast.ts +0 -66
  147. package/src/experimental/withToast.ts +0 -99
  148. package/tsconfig.json.bak +0 -12
package/test/stubs.ts CHANGED
@@ -1,18 +1,23 @@
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"
9
+ import * as S from "effect-app/Schema"
10
+ import * as Exit from "effect/Exit"
11
+ import * as ManagedRuntime from "effect/ManagedRuntime"
7
12
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
8
- import { ref } from "vue"
9
- import { Commander } from "../src/experimental/commander.js"
10
- import { I18n } from "../src/experimental/intl.js"
11
- import { makeUseCommand } from "../src/experimental/makeUseCommand.js"
12
- import * as Toast from "../src/experimental/toast.js"
13
- import { WithToast } from "../src/experimental/withToast.js"
14
- import { LegacyMutation, makeClient } from "../src/makeClient.js"
13
+ import { createApp, ref } from "vue"
14
+ import { Commander } from "../src/commander.js"
15
+ import { I18n } from "../src/intl.js"
16
+ import { makeClient } from "../src/makeClient.js"
15
17
  import { type MakeIntlReturn } from "../src/makeIntl.js"
18
+ import { makeUseCommand } from "../src/makeUseCommand.js"
19
+ import * as Toast from "../src/toast.js"
20
+ import { WithToast } from "../src/withToast.js"
16
21
 
17
22
  const fakeToastLayer = (toasts: any[] = []) =>
18
23
  Layer.effect(
@@ -26,33 +31,40 @@ const fakeToastLayer = (toasts: any[] = []) =>
26
31
  toasts.splice(idx, 1)
27
32
  }
28
33
  }
29
- const fakeToast = (message: string, options?: Toast.ToastOpts) => {
30
- const id = options?.id ?? Math.random().toString(36).substring(2, 15)
31
- console.log(`Toast [${id}]: ${message}`, options)
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
- options = { ...options, id }
34
- const idx = toasts.findIndex((_) => _.id === id)
35
- if (idx > -1) {
36
- const toast = toasts[idx]
37
- clearTimeout(toast.timeoutId)
38
- Object.assign(toast, { message, options })
39
- toast.timeoutId = setTimeout(() => {
40
- toasts.splice(idx, 1)
41
- }, options?.timeout ?? 3000)
42
- } else {
43
- const toast: any = { id, message, options }
44
- toast.timeoutId = setTimeout(() => {
45
- toasts.splice(idx, 1)
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,21 +103,174 @@ 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
- export const { TaggedRequest: Req } = makeRpcClient(RequestContextMap)
96
- export class GetSomething2 extends Req<GetSomething2>()("GetSomething2", {
122
+ const stubMiddleware = {
123
+ requestContextMap: RequestContextMap.config,
124
+ requestContext: undefined as never
125
+ }
126
+ export const { TaggedRequestFor } = makeRpcClient(stubMiddleware)
127
+
128
+ export const SomethingReq = TaggedRequestFor("Something")
129
+ const SomethingQuery = SomethingReq.Query
130
+ const SomethingCommand = SomethingReq.Command
131
+
132
+ class SomethingGetSomething2 extends SomethingQuery<SomethingGetSomething2>()("GetSomething2", {
97
133
  id: S.String
98
134
  }, { success: S.FiniteFromString }) {}
99
135
 
100
- export class GetSomething2WithDependencies extends Req<GetSomething2WithDependencies>()("GetSomething2", {
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
+
144
+ class SomethingGetSomething2WithDependencies
145
+ extends SomethingQuery<SomethingGetSomething2WithDependencies>()("GetSomething2", {
146
+ id: S.String
147
+ }, {
148
+ // this is intentilally fake, to simulate a codec that requires a dependency
149
+ success: S.FiniteFromString as S.Codec<number, string, "dep-a">,
150
+ error: S.String
151
+ })
152
+ {}
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", {
101
180
  id: S.String
102
181
  }, {
103
- // this is intentilally fake, to simulate a codec that requires a dependency
104
- success: S.FiniteFromString as S.Codec<number, string, "dep-a">,
105
- error: S.String
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])
106
227
  }) {}
107
228
 
108
- export const Something = { GetSomething2, GetSomething2WithDependencies, meta: { moduleName: "Something" as const } }
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
+
238
+ export const Something = {
239
+ GetSomething2: SomethingGetSomething2,
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
252
+ }
253
+
254
+ export const SomethingElseReq = TaggedRequestFor("SomethingElse")
255
+ const SomethingElseQuery = SomethingElseReq.Query
256
+
257
+ class SomethingElseGetSomething2 extends SomethingElseQuery<SomethingElseGetSomething2>()("GetSomething2", {
258
+ id: S.String
259
+ }, { success: S.FiniteFromString }) {}
260
+
261
+ class SomethingElseGetSomething2WithDependencies
262
+ extends SomethingElseQuery<SomethingElseGetSomething2WithDependencies>()("GetSomething2", {
263
+ id: S.String
264
+ }, {
265
+ success: S.FiniteFromString as S.Codec<number, string, "dep-a">,
266
+ error: S.String
267
+ })
268
+ {}
269
+
270
+ export const SomethingElse = {
271
+ GetSomething2: SomethingElseGetSomething2,
272
+ GetSomething2WithDependencies: SomethingElseGetSomething2WithDependencies
273
+ }
109
274
 
110
275
  export const useClient = (
111
276
  options?: { messages?: Record<string, string> | Record<string, MessageFormatElement[]>; toasts: any[] }
@@ -117,9 +282,29 @@ export const useClient = (
117
282
  const api = ApiClientFactory.layer({ url: "bogus", headers: Option.none() }).pipe(
118
283
  Layer.provide(FetchHttpClient.layer)
119
284
  )
120
- const lm = LegacyMutation.Default.pipe(Layer.provide([FakeIntlLayer, FakeToastLayer]))
121
- const layers = Layer.mergeAll(CommanderLayer, WithToastLayer, FakeToastLayer, FakeIntlLayer, api, lm)
285
+ const layers = Layer.mergeAll(CommanderLayer, WithToastLayer, FakeToastLayer, FakeIntlLayer, api)
122
286
 
123
287
  const clientFor_ = ApiClientFactory.makeFor(Layer.empty)
124
- return makeClient(() => ManagedRuntime.make(layers), clientFor_, Layer.empty)
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 }
125
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/unstable/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
+ }) as any,
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) as any
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/unstable/reactivity/AsyncResult"
4
+ import * as Atom from "effect/unstable/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"
@@ -32,9 +37,11 @@
32
37
  "outDir": "build/dist",
33
38
  "resolveJsonModule": true,
34
39
  "moduleResolution": "Node16",
35
- "downlevelIteration": true,
36
40
  "noErrorTruncation": true,
37
41
  "forceConsistentCasingInFileNames": true,
42
+ "erasableSyntaxOnly": true,
43
+ "rewriteRelativeImportExtensions": true,
44
+ "allowImportingTsExtensions": true,
38
45
  "types": [
39
46
  "node"
40
47
  ]