@effect-app/vue 4.0.0-beta.273 → 4.0.0-beta.275
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 +15 -0
- package/dist/atomQuery.d.ts +9 -1
- package/dist/atomQuery.d.ts.map +1 -1
- package/dist/atomQuery.js +15 -7
- package/dist/internal/tanstackQuery.d.ts +2 -1
- package/dist/internal/tanstackQuery.d.ts.map +1 -1
- package/dist/internal/tanstackQuery.js +13 -3
- package/dist/makeClient.d.ts.map +1 -1
- package/dist/makeClient.js +53 -31
- package/dist/query.d.ts +8 -1
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +59 -25
- package/docs/atom-query-api-redesign.md +16 -0
- package/package.json +2 -2
- package/src/atomQuery.ts +26 -6
- package/src/internal/tanstackQuery.ts +24 -3
- package/src/makeClient.ts +80 -36
- package/src/query.ts +76 -27
- package/test/dist/patchableQueryAtom.test.d.ts.map +1 -0
- package/test/dist/queryOptions.test.d.ts.map +1 -0
- package/test/dist/retrySchedule.test.d.ts.map +1 -0
- package/test/suspense.test.ts +141 -1
- package/tsconfig.json.bak +14 -72
package/src/atomQuery.ts
CHANGED
|
@@ -206,6 +206,28 @@ export interface AtomQueryOptions {
|
|
|
206
206
|
|
|
207
207
|
const defaults = { staleTime: Duration.seconds(5), gcTime: Duration.minutes(5) }
|
|
208
208
|
|
|
209
|
+
export interface AtomQueryMetadata {
|
|
210
|
+
readonly staleTimeMs: number
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const atomQueryMetadata = new WeakMap<Atom.Atom<AsyncResult.AsyncResult<any, any>>, AtomQueryMetadata>()
|
|
214
|
+
|
|
215
|
+
const setAtomQueryMetadata = <A, E>(
|
|
216
|
+
atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
|
|
217
|
+
opts: AtomQueryOptions = {}
|
|
218
|
+
) => {
|
|
219
|
+
const staleTimeMs = staleTimeMsOf(opts)
|
|
220
|
+
const previous = atomQueryMetadata.get(atom)
|
|
221
|
+
atomQueryMetadata.set(atom, {
|
|
222
|
+
staleTimeMs: previous === undefined ? staleTimeMs : Math.min(previous.staleTimeMs, staleTimeMs)
|
|
223
|
+
})
|
|
224
|
+
return atom
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const getAtomQueryMetadata = <A, E>(
|
|
228
|
+
atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
|
|
229
|
+
): AtomQueryMetadata | undefined => atomQueryMetadata.get(atom)
|
|
230
|
+
|
|
209
231
|
/** Exported so the vue hook can do refetch-on-mount-per-observer with the same rule as swr. */
|
|
210
232
|
export const isStaleResult = (r: AsyncResult.AsyncResult<any, any>, staleTimeMs: number): boolean => {
|
|
211
233
|
if (r.waiting) return false
|
|
@@ -225,10 +247,8 @@ export const withQueryOptions = <A, E>(
|
|
|
225
247
|
self: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
|
|
226
248
|
opts: AtomQueryOptions = {}
|
|
227
249
|
): Atom.Atom<AsyncResult.AsyncResult<A, E>> => {
|
|
250
|
+
setAtomQueryMetadata(self, opts)
|
|
228
251
|
const staleTime: Duration.Input = opts.staleTime ?? defaults.staleTime
|
|
229
|
-
const gcTime = opts.gcTime === "infinity"
|
|
230
|
-
? "infinity" as const
|
|
231
|
-
: Duration.fromInputUnsafe(opts.gcTime ?? defaults.gcTime)
|
|
232
252
|
let atom = self
|
|
233
253
|
const revalidateOnFocus = opts.revalidateOnFocus ?? true
|
|
234
254
|
atom = Atom.swr({
|
|
@@ -238,7 +258,7 @@ export const withQueryOptions = <A, E>(
|
|
|
238
258
|
})(atom)
|
|
239
259
|
if (opts.refetchInterval) atom = Atom.withRefresh(Duration.millis(opts.refetchInterval))(atom)
|
|
240
260
|
if (opts.structuralSharing ?? true) atom = structuralShare(atom)
|
|
241
|
-
return
|
|
261
|
+
return atom
|
|
242
262
|
}
|
|
243
263
|
|
|
244
264
|
/** Constant atom for disabled / `mode:"optional"`-None queries: stays Initial, never fetches. */
|
|
@@ -321,7 +341,7 @@ export const buildQueryFamily = <I, A, E>(
|
|
|
321
341
|
// gcTime LAST so the whole chain (incl. the registration + tracking) stays alive through the
|
|
322
342
|
// idle window, letting invalidation reach a cached-but-unmounted query.
|
|
323
343
|
atom = Atom.setIdleTTL(atom, defaults.gcTime)
|
|
324
|
-
return Atom.withLabel(`query:${self.id}`)(atom)
|
|
344
|
+
return setAtomQueryMetadata(Atom.withLabel(`query:${self.id}`)(atom))
|
|
325
345
|
})
|
|
326
346
|
}
|
|
327
347
|
|
|
@@ -350,7 +370,7 @@ export const buildStreamQueryFamily = <I, A, E>(
|
|
|
350
370
|
atom = rt.factory.withReactivity(reactivityKeys)(atom)
|
|
351
371
|
atom = trackWritableByKeys(reactivityKeys)(atom)
|
|
352
372
|
atom = Atom.setIdleTTL(atom, defaults.gcTime)
|
|
353
|
-
return Atom.withLabel(`stream-query:${self.id}`)(atom)
|
|
373
|
+
return setAtomQueryMetadata(Atom.withLabel(`stream-query:${self.id}`)(atom))
|
|
354
374
|
})
|
|
355
375
|
}
|
|
356
376
|
|
|
@@ -14,7 +14,7 @@ import * as Atom from "effect/unstable/reactivity/Atom"
|
|
|
14
14
|
import { computed, type MaybeRefOrGetter, shallowRef, toValue, watch, type WatchSource } from "vue"
|
|
15
15
|
import { reportRuntimeError } from "../lib.ts"
|
|
16
16
|
import type { QueryInvalidator } from "../mutate.ts"
|
|
17
|
-
import type { CustomDefinedInitialQueryOptions, CustomDefinedPlaceholderQueryOptions, CustomUndefinedInitialQueryOptions, CustomUseQueryOptions, MakeQuery2, QueryHandle, QueryObserverResult, RefetchOptions } from "../query.ts"
|
|
17
|
+
import type { CustomDefinedInitialQueryOptions, CustomDefinedPlaceholderQueryOptions, CustomUndefinedInitialQueryOptions, CustomUseQueryOptions, MakeQuery2, QueryCacheUpdater, QueryHandle, QueryObserverResult, RefetchOptions } from "../query.ts"
|
|
18
18
|
import { makeRunPromise } from "../runtime.ts"
|
|
19
19
|
|
|
20
20
|
const swrToQuery = <E, A>(r: {
|
|
@@ -104,6 +104,28 @@ export const makeTanstackQueryInvalidator = (queryClient: QueryClient): QueryInv
|
|
|
104
104
|
)
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
+
const fullQueryKey = (
|
|
108
|
+
q: { readonly queryKeyProjectionHash?: string },
|
|
109
|
+
queryKey: ReadonlyArray<unknown>,
|
|
110
|
+
input: unknown
|
|
111
|
+
) => q.queryKeyProjectionHash === undefined ? [...queryKey, input] : [...queryKey, q.queryKeyProjectionHash, input]
|
|
112
|
+
|
|
113
|
+
export const makeTanstackQueryCacheUpdater = (queryClient: QueryClient): QueryCacheUpdater => ({
|
|
114
|
+
update: <I, A, E, R, Request extends Req, Name extends string>(
|
|
115
|
+
_registry: ReturnType<typeof injectRegistry>,
|
|
116
|
+
query: RequestHandlerWithInput<I, A, E, R, Request, Name>,
|
|
117
|
+
input: I,
|
|
118
|
+
updater: (data: NoInfer<A>) => NoInfer<A>
|
|
119
|
+
) => {
|
|
120
|
+
const queryKey = fullQueryKey(query, makeQueryKey(query), input)
|
|
121
|
+
if (queryClient.getQueryData(queryKey) === undefined) {
|
|
122
|
+
console.warn(`Query ${query.id} has not been used yet; nothing to update`)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
queryClient.setQueryData(queryKey, (data: A | undefined) => data === undefined ? data : updater(data))
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
107
129
|
export const makeTanstackQuery = <R>(
|
|
108
130
|
getRuntime: () => Context.Context<R>,
|
|
109
131
|
queryClient: QueryClient
|
|
@@ -117,7 +139,6 @@ export const makeTanstackQuery = <R>(
|
|
|
117
139
|
) => {
|
|
118
140
|
const runPromise = makeRunPromise(getRuntime())
|
|
119
141
|
const queryKey = makeQueryKey(q)
|
|
120
|
-
const projectionHash = q.queryKeyProjectionHash
|
|
121
142
|
const enabled = resolveEnabled(arg, options)
|
|
122
143
|
const tanstackOptions = {
|
|
123
144
|
...(options?.staleTime !== undefined ? { staleTime: options.staleTime } : {}),
|
|
@@ -134,7 +155,7 @@ export const makeTanstackQuery = <R>(
|
|
|
134
155
|
retry: (retryCount: number, error: unknown) => isRetryable(error) && retryCount < 5,
|
|
135
156
|
queryKey: computed(() => {
|
|
136
157
|
const input = resolveInput(arg, options?.mode)
|
|
137
|
-
return
|
|
158
|
+
return fullQueryKey(q, queryKey, input)
|
|
138
159
|
}),
|
|
139
160
|
queryFn: ({ signal }: { readonly signal: AbortSignal }) =>
|
|
140
161
|
runPromise(
|
package/src/makeClient.ts
CHANGED
|
@@ -16,28 +16,58 @@ import type * as Stream from "effect/Stream"
|
|
|
16
16
|
import * as Struct from "effect/Struct"
|
|
17
17
|
import type * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
|
|
18
18
|
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
|
|
19
|
-
import { computed, type ComputedRef, onBeforeUnmount, ref, type WatchSource } from "vue"
|
|
19
|
+
import { computed, type ComputedRef, effectScope, onBeforeUnmount, onScopeDispose, ref, type WatchSource } from "vue"
|
|
20
20
|
import { type AtomClientRuntime, invalidateAndAwait, makeAtomClientRuntime } from "./atomQuery.ts"
|
|
21
21
|
import { type Commander, CommanderStatic, type Progress } from "./commander.ts"
|
|
22
|
-
import { makeTanstackQuery, makeTanstackQueryClient, makeTanstackQueryInvalidator } from "./internal/tanstackQuery.ts"
|
|
22
|
+
import { makeTanstackQuery, makeTanstackQueryCacheUpdater, makeTanstackQueryClient, makeTanstackQueryInvalidator } from "./internal/tanstackQuery.ts"
|
|
23
23
|
import { type I18n } from "./intl.ts"
|
|
24
24
|
import { type CommanderResolved, makeUseCommand } from "./makeUseCommand.ts"
|
|
25
25
|
import { atomQueryInvalidator, combineQueryInvalidators, type InvalidationEntry, makeMutation, makeStreamMutation2, type MutationOptionsBase, type QueryInvalidator, useMakeMutation } from "./mutate.ts"
|
|
26
|
-
import { type AtomQueryNewOptions, type CustomUndefinedInitialQueryOptions, makeQuery, makeQueryAtom, makeQueryFamily, makeQueryNew, makeStreamQuery, makeStreamQueryAtom, makeStreamQueryFamily, makeStreamQueryNew, type QueryObserverResult, type RefetchOptions, type StreamQueryAtomFamily, type SuspenseQueryView, type UseQueryReturnType } from "./query.ts"
|
|
26
|
+
import { atomQueryCacheUpdater, type AtomQueryNewOptions, combineQueryCacheUpdaters, type CustomUndefinedInitialQueryOptions, makeQuery, makeQueryAtom, makeQueryFamily, makeQueryNew, makeStreamQuery, makeStreamQueryAtom, makeStreamQueryFamily, makeStreamQueryNew, optionalAtomQueryCacheUpdater, type QueryObserverResult, type RefetchOptions, setQueryCacheUpdater, type StreamQueryAtomFamily, type SuspenseQueryView, type UseQueryReturnType } from "./query.ts"
|
|
27
27
|
import { makeRunPromise } from "./runtime.ts"
|
|
28
28
|
import { type Toast } from "./toast.ts"
|
|
29
29
|
|
|
30
30
|
export type { Progress }
|
|
31
31
|
|
|
32
|
+
const useScopedSuspenseSetup = <A>(setup: () => A) => {
|
|
33
|
+
const scope = effectScope()
|
|
34
|
+
const controller = new AbortController()
|
|
35
|
+
const value = scope.run(setup)
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
throw new Error("Internal Error: suspense setup scope did not initialize")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isMounted = ref(true)
|
|
41
|
+
let stopped = false
|
|
42
|
+
const stop = () => {
|
|
43
|
+
if (stopped) return
|
|
44
|
+
stopped = true
|
|
45
|
+
isMounted.value = false
|
|
46
|
+
controller.abort()
|
|
47
|
+
scope.stop()
|
|
48
|
+
}
|
|
49
|
+
onBeforeUnmount(stop)
|
|
50
|
+
onScopeDispose(stop)
|
|
51
|
+
|
|
52
|
+
return [value, isMounted, controller.signal] as const
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
// TODO: optimize - work from encoded shape directly
|
|
33
|
-
const projectHandler = <
|
|
56
|
+
const projectHandler = <
|
|
57
|
+
I,
|
|
58
|
+
A,
|
|
59
|
+
E,
|
|
60
|
+
R,
|
|
61
|
+
SuccessSchema extends S.Top & { readonly "EncodingServices": R },
|
|
62
|
+
ProjSchema extends S.Top & { readonly "DecodingServices": R }
|
|
63
|
+
>(
|
|
34
64
|
handler: (i: I) => Effect.Effect<A, E, R>,
|
|
35
|
-
successSchema:
|
|
65
|
+
successSchema: SuccessSchema,
|
|
36
66
|
projectionSchema: ProjSchema
|
|
37
67
|
) => {
|
|
38
|
-
const encode = S.
|
|
39
|
-
const decode = S.
|
|
40
|
-
return (i: I) => handler(i).pipe(Effect.
|
|
68
|
+
const encode = S.encodeUnknownEffect(successSchema)
|
|
69
|
+
const decode = S.decodeUnknownEffect(projectionSchema)
|
|
70
|
+
return (i: I) => handler(i).pipe(Effect.flatMap(encode), Effect.flatMap(decode))
|
|
41
71
|
}
|
|
42
72
|
|
|
43
73
|
const projectionSchemaHash = (schema: S.Top) => String(Hash.hash(schema.ast))
|
|
@@ -484,18 +514,20 @@ export class QueryImpl<R> {
|
|
|
484
514
|
argOrOptions: I | WatchSource<I>,
|
|
485
515
|
options?: CustomUndefinedInitialQueryOptions<A, CauseException<E>, TData>
|
|
486
516
|
) => {
|
|
487
|
-
const [
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
517
|
+
const [
|
|
518
|
+
[resultRef, latestDefinedRef, fetch, uqrt],
|
|
519
|
+
isMounted,
|
|
520
|
+
signal
|
|
521
|
+
] = useScopedSuspenseSetup(() => {
|
|
522
|
+
const [resultRef, latestRef, fetch, uqrt] = q<TData>(argOrOptions, options)
|
|
523
|
+
const latestDefinedRef = computed<TData>(() => {
|
|
524
|
+
const latest = latestRef.value
|
|
525
|
+
if (latest === undefined) {
|
|
526
|
+
throw new Error("Internal Error: suspense resolved without a latest value")
|
|
527
|
+
}
|
|
528
|
+
return latest
|
|
529
|
+
})
|
|
530
|
+
return [resultRef, latestDefinedRef, fetch, uqrt] as const
|
|
499
531
|
})
|
|
500
532
|
|
|
501
533
|
// @effect-diagnostics effect/missingEffectError:off
|
|
@@ -511,7 +543,7 @@ export class QueryImpl<R> {
|
|
|
511
543
|
return [resultRef, latestDefinedRef, fetch, uqrt] as const
|
|
512
544
|
})
|
|
513
545
|
|
|
514
|
-
return runPromise(eff)
|
|
546
|
+
return runPromise(eff, { signal })
|
|
515
547
|
}
|
|
516
548
|
}
|
|
517
549
|
|
|
@@ -539,18 +571,16 @@ export class QueryImpl<R> {
|
|
|
539
571
|
argOrOptions: I | WatchSource<I>,
|
|
540
572
|
options?: AtomQueryNewOptions<A, TData>
|
|
541
573
|
) => {
|
|
542
|
-
const view =
|
|
543
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
onBeforeUnmount(() => {
|
|
553
|
-
isMounted.value = false
|
|
574
|
+
const [{ view, data }, isMounted, signal] = useScopedSuspenseSetup(() => {
|
|
575
|
+
const view = q<TData>(argOrOptions, options)
|
|
576
|
+
const data = computed<TData>(() => {
|
|
577
|
+
const latest = view.data.value
|
|
578
|
+
if (latest === undefined) {
|
|
579
|
+
throw new Error("Internal Error: suspenseNew resolved without a latest value")
|
|
580
|
+
}
|
|
581
|
+
return latest
|
|
582
|
+
})
|
|
583
|
+
return { view, data }
|
|
554
584
|
})
|
|
555
585
|
|
|
556
586
|
// @effect-diagnostics effect/missingEffectError:off
|
|
@@ -585,7 +615,7 @@ export class QueryImpl<R> {
|
|
|
585
615
|
)
|
|
586
616
|
})
|
|
587
617
|
|
|
588
|
-
return runPromise(eff)
|
|
618
|
+
return runPromise(eff, { signal })
|
|
589
619
|
}
|
|
590
620
|
}
|
|
591
621
|
}
|
|
@@ -694,6 +724,14 @@ export const makeClient = <RT_, RTHooks>(
|
|
|
694
724
|
const queryInvalidator = legacyQueryEngine === "tanstack"
|
|
695
725
|
? combineQueryInvalidators(atomInvalidator, makeTanstackQueryInvalidator(getTanstackQueryClient()))
|
|
696
726
|
: atomInvalidator
|
|
727
|
+
setQueryCacheUpdater(
|
|
728
|
+
legacyQueryEngine === "tanstack"
|
|
729
|
+
? combineQueryCacheUpdaters(
|
|
730
|
+
makeTanstackQueryCacheUpdater(getTanstackQueryClient()),
|
|
731
|
+
optionalAtomQueryCacheUpdater
|
|
732
|
+
)
|
|
733
|
+
: atomQueryCacheUpdater
|
|
734
|
+
)
|
|
697
735
|
|
|
698
736
|
let m: ReturnType<typeof useMutationInt>
|
|
699
737
|
const useMutation = () => m ??= useMutationInt(queryInvalidator)
|
|
@@ -742,10 +780,16 @@ export const makeClient = <RT_, RTHooks>(
|
|
|
742
780
|
queryNew: useStreamQueryNew(handler)
|
|
743
781
|
})
|
|
744
782
|
|
|
745
|
-
const projectQueryFor = <
|
|
783
|
+
const projectQueryFor = <
|
|
784
|
+
I,
|
|
785
|
+
A,
|
|
786
|
+
E,
|
|
787
|
+
Request extends Req & { readonly success: S.Top & { readonly "EncodingServices": RT } },
|
|
788
|
+
Name extends string
|
|
789
|
+
>(
|
|
746
790
|
handler: RequestHandlerWithInput<I, A, E, RT, Request, Name>
|
|
747
791
|
) =>
|
|
748
|
-
<ProjSchema extends S.
|
|
792
|
+
<ProjSchema extends S.Top & { readonly "DecodingServices": RT }>(projectionSchema: ProjSchema) => {
|
|
749
793
|
const successSchema = handler.Request.success
|
|
750
794
|
const projectionHash = projectionSchemaHash(projectionSchema)
|
|
751
795
|
const projected = projectHandler(handler.handler, successSchema, projectionSchema)
|
package/src/query.ts
CHANGED
|
@@ -16,7 +16,7 @@ import * as Exit from "effect/Exit"
|
|
|
16
16
|
import * as Stream from "effect/Stream"
|
|
17
17
|
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
|
|
18
18
|
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
19
|
-
import { computed, type ComputedRef, type MaybeRefOrGetter, onBeforeUnmount, onMounted, ref, toValue, type WatchSource } from "vue"
|
|
19
|
+
import { computed, type ComputedRef, effectScope, type MaybeRefOrGetter, onBeforeUnmount, onMounted, onScopeDispose, ref, toValue, type WatchSource } from "vue"
|
|
20
20
|
import { type AtomClientRuntime, type AtomQueryOptions, awaitAtomResult, buildQueryFamily, buildStreamQueryFamily, disabledQueryAtom, isStaleResult, staleTimeMsOf, withQueryOptions } from "./atomQuery.ts"
|
|
21
21
|
|
|
22
22
|
// --- minimal local types (replacing the former @tanstack/vue-query type imports) ---
|
|
@@ -40,6 +40,38 @@ export interface QueryView<A, E> extends QueryHandle<A, E> {
|
|
|
40
40
|
readonly data: ComputedRef<A | undefined>
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
const useScopedSuspenseSetup = <A>(setup: () => A) => {
|
|
44
|
+
const scope = effectScope()
|
|
45
|
+
const controller = new AbortController()
|
|
46
|
+
const value = scope.run(setup)
|
|
47
|
+
if (value === undefined) {
|
|
48
|
+
throw new Error("Internal Error: atom suspense setup scope did not initialize")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isMounted = ref(true)
|
|
52
|
+
let stopped = false
|
|
53
|
+
const stop = () => {
|
|
54
|
+
if (stopped) return
|
|
55
|
+
stopped = true
|
|
56
|
+
isMounted.value = false
|
|
57
|
+
controller.abort()
|
|
58
|
+
scope.stop()
|
|
59
|
+
}
|
|
60
|
+
onBeforeUnmount(stop)
|
|
61
|
+
onScopeDispose(stop)
|
|
62
|
+
|
|
63
|
+
return [value, isMounted, controller.signal] as const
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface QueryCacheUpdater {
|
|
67
|
+
readonly update: <I, A, E, R, Request extends Req, Name extends string>(
|
|
68
|
+
registry: ReturnType<typeof injectRegistry>,
|
|
69
|
+
query: RequestHandlerWithInput<I, A, E, R, Request, Name>,
|
|
70
|
+
input: I,
|
|
71
|
+
updater: (data: NoInfer<A>) => NoInfer<A>
|
|
72
|
+
) => void
|
|
73
|
+
}
|
|
74
|
+
|
|
43
75
|
// retained generic aliases so the exported option-interface arity is unchanged for consumers
|
|
44
76
|
export type UseQueryReturnType<A = any, E = any> = QueryHandle<A, E>
|
|
45
77
|
export type UseQueryDefinedReturnType<A = any, E = any> = QueryHandle<A, E>
|
|
@@ -111,6 +143,35 @@ const getStreamQueryFamily = <I, A, E>(
|
|
|
111
143
|
return f
|
|
112
144
|
}
|
|
113
145
|
|
|
146
|
+
const makeAtomQueryCacheUpdater = (warnIfMissing: boolean): QueryCacheUpdater => ({
|
|
147
|
+
update: (registry, query, input) => {
|
|
148
|
+
const family = queryFamilyByKey.get(queryFamilyCacheKey(query))
|
|
149
|
+
if (!family) {
|
|
150
|
+
if (warnIfMissing) {
|
|
151
|
+
console.warn(`Query ${query.id} has not been used yet; nothing to update`)
|
|
152
|
+
}
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
registry.refresh(family(input))
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
export const atomQueryCacheUpdater = makeAtomQueryCacheUpdater(true)
|
|
160
|
+
export const optionalAtomQueryCacheUpdater = makeAtomQueryCacheUpdater(false)
|
|
161
|
+
|
|
162
|
+
export const combineQueryCacheUpdaters = (first: QueryCacheUpdater, second: QueryCacheUpdater): QueryCacheUpdater => ({
|
|
163
|
+
update: (registry, query, input, updater) => {
|
|
164
|
+
first.update(registry, query, input, updater)
|
|
165
|
+
second.update(registry, query, input, updater)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
let activeQueryCacheUpdater = atomQueryCacheUpdater
|
|
170
|
+
|
|
171
|
+
export const setQueryCacheUpdater = (updater: QueryCacheUpdater) => {
|
|
172
|
+
activeQueryCacheUpdater = updater
|
|
173
|
+
}
|
|
174
|
+
|
|
114
175
|
// Atom-engine query options (formerly reconstructed from @tanstack/vue-query types).
|
|
115
176
|
// The generic arity is kept so the exported interface signatures are unchanged for consumers.
|
|
116
177
|
export interface CustomUseQueryOptions<
|
|
@@ -252,18 +313,16 @@ export const useAtomQuery = <A, E>(
|
|
|
252
313
|
export const useAtomSuspense = <A, E>(
|
|
253
314
|
atom: () => Atom.Atom<AsyncResult.AsyncResult<A, E>>
|
|
254
315
|
): Promise<SuspenseQueryView<A, E>> => {
|
|
255
|
-
const view =
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
onBeforeUnmount(() => {
|
|
266
|
-
isMounted.value = false
|
|
316
|
+
const [{ view, data }, isMounted, signal] = useScopedSuspenseSetup(() => {
|
|
317
|
+
const view = useAtomQuery(atom)
|
|
318
|
+
const data = computed<A>(() => {
|
|
319
|
+
const latest = view.data.value
|
|
320
|
+
if (latest === undefined) {
|
|
321
|
+
throw new Error("Internal Error: atom suspense resolved without a latest value")
|
|
322
|
+
}
|
|
323
|
+
return latest
|
|
324
|
+
})
|
|
325
|
+
return { view, data }
|
|
267
326
|
})
|
|
268
327
|
|
|
269
328
|
const eff = Effect.gen(function*() {
|
|
@@ -297,7 +356,7 @@ export const useAtomSuspense = <A, E>(
|
|
|
297
356
|
)
|
|
298
357
|
})
|
|
299
358
|
|
|
300
|
-
return Effect.runPromise(eff)
|
|
359
|
+
return Effect.runPromise(eff, { signal })
|
|
301
360
|
}
|
|
302
361
|
|
|
303
362
|
export type StreamQueryPullValue<A> = {
|
|
@@ -795,22 +854,12 @@ export function composeQueries<
|
|
|
795
854
|
export const useUpdateQuery = () => {
|
|
796
855
|
const registry = injectRegistry()
|
|
797
856
|
|
|
798
|
-
// NOTE: query atoms are derived (read-only) here, so unlike tanstack's `setQueryData` we can't
|
|
799
|
-
// optimistically patch the cache in place — this refetches the query (the `updater` is ignored).
|
|
800
|
-
// A first-class optimistic-update layer is planned for the atom-native redesign.
|
|
801
857
|
const f: {
|
|
802
|
-
<I, A>(
|
|
803
|
-
query: RequestHandlerWithInput<I, A,
|
|
858
|
+
<I, A, E, R, Request extends Req, Name extends string>(
|
|
859
|
+
query: RequestHandlerWithInput<I, A, E, R, Request, Name>,
|
|
804
860
|
input: I,
|
|
805
861
|
updater: (data: NoInfer<A>) => NoInfer<A>
|
|
806
862
|
): void
|
|
807
|
-
} = (query
|
|
808
|
-
const family = queryFamilyByKey.get(queryFamilyCacheKey(query))
|
|
809
|
-
if (!family) {
|
|
810
|
-
console.warn(`Query ${query.id} has not been used yet; nothing to update`)
|
|
811
|
-
return
|
|
812
|
-
}
|
|
813
|
-
registry.refresh(family(input))
|
|
814
|
-
}
|
|
863
|
+
} = (query, input, updater) => activeQueryCacheUpdater.update(registry, query, input, updater)
|
|
815
864
|
return f
|
|
816
865
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"patchableQueryAtom.test.d.ts","sourceRoot":"","sources":["../patchableQueryAtom.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queryOptions.test.d.ts","sourceRoot":"","sources":["../queryOptions.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retrySchedule.test.d.ts","sourceRoot":"","sources":["../retrySchedule.test.ts"],"names":[],"mappings":""}
|
package/test/suspense.test.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { defaultRegistry, registryKey } from "@effect/atom-vue"
|
|
1
2
|
import { Effect, Option } from "effect-app"
|
|
2
3
|
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
|
|
3
|
-
import
|
|
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"
|
|
4
8
|
import { awaitResolvedSuspenseResult } from "../src/suspense.js"
|
|
5
9
|
|
|
10
|
+
const listenerCount = (atom: Atom.Atom<unknown>) => defaultRegistry.getNodes().get(atom)?.listeners.size ?? 0
|
|
11
|
+
|
|
6
12
|
it("waits for the query result ref after suspense resolves", async () => {
|
|
7
13
|
const result = ref<AsyncResult.AsyncResult<number, never>>(AsyncResult.initial(true))
|
|
8
14
|
const promise = Effect.runPromise(awaitResolvedSuspenseResult(computed(() => result.value)))
|
|
@@ -18,3 +24,137 @@ it("keeps unresolved query results initial", async () => {
|
|
|
18
24
|
|
|
19
25
|
expect(AsyncResult.isInitial(settled)).toBe(true)
|
|
20
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
|
+
})
|