@effect-app/vue 4.0.0-beta.98 → 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 +2707 -0
- package/dist/atomQuery.d.ts +120 -0
- package/dist/atomQuery.d.ts.map +1 -0
- package/dist/atomQuery.js +434 -0
- package/dist/commander.d.ts +318 -43
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +593 -81
- package/dist/confirm.d.ts +5 -3
- package/dist/confirm.d.ts.map +1 -1
- package/dist/confirm.js +12 -14
- package/dist/dependencyMetadata.d.ts +18 -0
- package/dist/dependencyMetadata.d.ts.map +1 -0
- package/dist/dependencyMetadata.js +67 -0
- package/dist/errorReporter.d.ts +6 -4
- package/dist/errorReporter.d.ts.map +1 -1
- package/dist/errorReporter.js +14 -19
- package/dist/form.d.ts +5 -5
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +16 -10
- package/dist/internal/tanstackQuery.d.ts +9 -0
- package/dist/internal/tanstackQuery.d.ts.map +1 -0
- package/dist/internal/tanstackQuery.js +204 -0
- package/dist/intl.d.ts +4 -4
- package/dist/intl.d.ts.map +1 -1
- package/dist/intl.js +2 -2
- package/dist/lib.d.ts +8 -10
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +37 -12
- package/dist/liveQueryInvalidation.d.ts +16 -0
- package/dist/liveQueryInvalidation.d.ts.map +1 -0
- package/dist/liveQueryInvalidation.js +115 -0
- package/dist/makeClient.d.ts +232 -109
- package/dist/makeClient.d.ts.map +1 -1
- package/dist/makeClient.js +416 -84
- package/dist/makeContext.d.ts.map +1 -1
- package/dist/makeIntl.d.ts.map +1 -1
- package/dist/makeUseCommand.d.ts +3 -2
- package/dist/makeUseCommand.d.ts.map +1 -1
- package/dist/makeUseCommand.js +2 -2
- package/dist/mutate.d.ts +110 -41
- package/dist/mutate.d.ts.map +1 -1
- package/dist/mutate.js +236 -59
- package/dist/query.d.ts +161 -44
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +382 -113
- package/dist/queryLifetime.d.ts +25 -0
- package/dist/queryLifetime.d.ts.map +1 -0
- package/dist/queryLifetime.js +54 -0
- package/dist/routeParams.d.ts +4 -4
- package/dist/routeParams.d.ts.map +1 -1
- package/dist/routeParams.js +4 -3
- package/dist/runtime.d.ts +1 -17
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +2 -38
- package/dist/suspense.d.ts +19 -0
- package/dist/suspense.d.ts.map +1 -0
- package/dist/suspense.js +38 -0
- package/dist/toast.d.ts +1 -45
- package/dist/toast.d.ts.map +1 -1
- package/dist/toast.js +2 -32
- package/dist/withToast.d.ts +1 -24
- package/dist/withToast.d.ts.map +1 -1
- package/dist/withToast.js +2 -45
- package/docs/atom-query-api-redesign.md +207 -0
- package/docs/mutation-command-atoms.md +40 -0
- package/docs/query-key-invalidation.md +107 -0
- package/examples/streamMutation.ts +72 -0
- package/package.json +35 -94
- package/src/atomQuery.ts +600 -0
- package/src/commander.ts +1064 -116
- package/src/confirm.ts +12 -14
- package/src/dependencyMetadata.ts +84 -0
- package/src/errorReporter.ts +65 -75
- package/src/form.ts +22 -15
- package/src/index.ts +7 -7
- package/src/internal/tanstackQuery.ts +308 -0
- package/src/intl.ts +2 -2
- package/src/lib.ts +51 -23
- package/src/liveQueryInvalidation.ts +138 -0
- package/src/makeClient.ts +972 -263
- package/src/makeIntl.ts +2 -2
- package/src/makeUseCommand.ts +8 -5
- package/src/mutate.ts +407 -154
- package/src/query.ts +758 -242
- package/src/queryLifetime.ts +74 -0
- package/src/routeParams.ts +7 -7
- package/src/runtime.ts +1 -54
- package/src/suspense.ts +43 -0
- package/src/toast.ts +1 -52
- package/src/withToast.ts +1 -99
- package/test/Mutation.test.ts +104 -17
- package/test/dependencyInvalidation.test.ts +444 -0
- package/test/dist/dependencyInvalidation.test.d.ts.map +1 -0
- package/test/dist/form.test.d.ts.map +1 -1
- package/test/dist/inactiveInvalidation.test.d.ts.map +1 -0
- package/test/dist/interrupt-refetch-repro.test.d.ts.map +1 -0
- package/test/dist/lib.test.d.ts.map +1 -0
- package/test/dist/liveQueryInvalidation.test.d.ts.map +1 -0
- package/test/dist/query-span.test.d.ts.map +1 -0
- package/test/dist/queryOptions.test.d.ts.map +1 -0
- package/test/dist/streamFinal.test.d.ts.map +1 -0
- package/test/dist/streamFn.test.d.ts.map +1 -0
- package/test/dist/stubs.d.ts +3681 -256
- package/test/dist/stubs.d.ts.map +1 -1
- package/test/dist/stubs.js +166 -25
- package/test/dist/suspense-regression.test.d.ts.map +1 -0
- package/test/dist/suspense.test.d.ts.map +1 -0
- package/test/form-validation-errors.test.ts +2 -1
- package/test/form.test.ts +2 -1
- package/test/inactiveInvalidation.test.ts +172 -0
- package/test/interrupt-refetch-repro.test.ts +625 -0
- package/test/lib.test.ts +240 -0
- package/test/liveQueryInvalidation.test.ts +108 -0
- package/test/makeClient.test.ts +414 -32
- package/test/query-span.test.ts +92 -0
- package/test/queryOptions.test.ts +88 -0
- package/test/streamFinal.test.ts +64 -0
- package/test/streamFn.test.ts +457 -0
- package/test/stubs.ts +194 -36
- package/test/suspense-regression.test.ts +168 -0
- package/test/suspense.test.ts +160 -0
- package/tsconfig.examples.json +20 -0
- package/tsconfig.json +9 -1
- package/tsconfig.src.json +34 -34
- package/tsconfig.test.json +2 -2
- package/vitest.config.ts +5 -5
- package/eslint.config.mjs +0 -24
- package/tsconfig.json.bak +0 -12
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type * as AsyncResult from "effect/reactivity/AsyncResult"
|
|
2
|
+
import * as Atom from "effect/reactivity/Atom"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-query observer count + invalidation flag. Lives on the family atom so
|
|
6
|
+
* invalidate can mark stale and refetch only when someone is looking
|
|
7
|
+
* (TanStack refetchType: "active").
|
|
8
|
+
*/
|
|
9
|
+
export interface QueryLifetime {
|
|
10
|
+
observers: number
|
|
11
|
+
invalidated: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const lifetimes = new WeakMap<Atom.Atom<any>, QueryLifetime>()
|
|
15
|
+
|
|
16
|
+
const rootOf = (atom: Atom.Atom<any>): Atom.Atom<any> => {
|
|
17
|
+
let target = atom
|
|
18
|
+
while (target.initialValueTarget !== undefined) {
|
|
19
|
+
target = target.initialValueTarget
|
|
20
|
+
}
|
|
21
|
+
return target
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const queryLifetimeOf = (atom: Atom.Atom<any>): QueryLifetime | undefined =>
|
|
25
|
+
lifetimes.get(rootOf(atom)) ?? lifetimes.get(atom)
|
|
26
|
+
|
|
27
|
+
export const attachQueryLifetime = <A, E>(
|
|
28
|
+
atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
|
|
29
|
+
): QueryLifetime => {
|
|
30
|
+
const existing = queryLifetimeOf(atom)
|
|
31
|
+
if (existing !== undefined) return existing
|
|
32
|
+
const created: QueryLifetime = { observers: 0, invalidated: false }
|
|
33
|
+
lifetimes.set(rootOf(atom), created)
|
|
34
|
+
lifetimes.set(atom, created)
|
|
35
|
+
return created
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Count this subscription as an observer of `family`. On remount, consume a
|
|
40
|
+
* pending invalidation and refresh the family atom.
|
|
41
|
+
*/
|
|
42
|
+
export const observeQueryAtom = <A, E>(
|
|
43
|
+
family: Atom.Atom<AsyncResult.AsyncResult<A, E>>
|
|
44
|
+
): Atom.Atom<AsyncResult.AsyncResult<A, E>> => {
|
|
45
|
+
const life = attachQueryLifetime(family)
|
|
46
|
+
return Atom.transform(family, (get) => {
|
|
47
|
+
life.observers++
|
|
48
|
+
get.addFinalizer(() => {
|
|
49
|
+
life.observers = Math.max(0, life.observers - 1)
|
|
50
|
+
})
|
|
51
|
+
if (life.invalidated) {
|
|
52
|
+
life.invalidated = false
|
|
53
|
+
get.refresh(family)
|
|
54
|
+
}
|
|
55
|
+
return get(family)
|
|
56
|
+
}, { initialValueTarget: family })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Idle queries with a lifetime are marked stale. Queries that still have
|
|
61
|
+
* observers (or have no lifetime, e.g. ad-hoc registry mounts of a raw
|
|
62
|
+
* effect atom) are refetched now.
|
|
63
|
+
*/
|
|
64
|
+
export const atomsToRefetch = <A, E>(
|
|
65
|
+
atoms: ReadonlyArray<Atom.Atom<AsyncResult.AsyncResult<A, E>>>
|
|
66
|
+
): ReadonlyArray<Atom.Atom<AsyncResult.AsyncResult<A, E>>> => {
|
|
67
|
+
const active: Array<Atom.Atom<AsyncResult.AsyncResult<A, E>>> = []
|
|
68
|
+
for (const atom of atoms) {
|
|
69
|
+
const life = queryLifetimeOf(atom)
|
|
70
|
+
if (life === undefined || life.observers > 0) active.push(atom)
|
|
71
|
+
else life.invalidated = true
|
|
72
|
+
}
|
|
73
|
+
return active
|
|
74
|
+
}
|
package/src/routeParams.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import type { Schema } from "effect-app/Schema"
|
|
1
|
+
import * as Option from "effect-app/Option"
|
|
2
|
+
import * as S from "effect-app/Schema"
|
|
4
3
|
import { typedKeysOf } from "effect-app/utils"
|
|
4
|
+
import { flow } from "effect/Function"
|
|
5
5
|
import type { ParsedQuery } from "query-string"
|
|
6
6
|
|
|
7
7
|
export function getQueryParam(search: ParsedQuery, param: string) {
|
|
@@ -16,7 +16,7 @@ export const getQueryParamO = flow(getQueryParam, Option.fromNullishOr)
|
|
|
16
16
|
|
|
17
17
|
export function parseRouteParamsOption<NER extends Record<string, S.Codec<any, any>>>(query: Record<string, any>, t: NER // enforce non empty
|
|
18
18
|
): {
|
|
19
|
-
[K in keyof NER]: Option.Option<
|
|
19
|
+
[K in keyof NER]: Option.Option<NER[K]["Type"]>
|
|
20
20
|
} {
|
|
21
21
|
return typedKeysOf(t).reduce(
|
|
22
22
|
(prev, cur) => {
|
|
@@ -28,7 +28,7 @@ export function parseRouteParamsOption<NER extends Record<string, S.Codec<any, a
|
|
|
28
28
|
return prev
|
|
29
29
|
},
|
|
30
30
|
{} as {
|
|
31
|
-
[K in keyof NER]: Option.Option<
|
|
31
|
+
[K in keyof NER]: Option.Option<NER[K]["Type"]>
|
|
32
32
|
}
|
|
33
33
|
)
|
|
34
34
|
}
|
|
@@ -37,7 +37,7 @@ export function parseRouteParams<NER extends Record<string, S.Codec<any, any>>>(
|
|
|
37
37
|
query: Record<string, any>,
|
|
38
38
|
t: NER // enforce non empty
|
|
39
39
|
): {
|
|
40
|
-
[K in keyof NER]:
|
|
40
|
+
[K in keyof NER]: NER[K]["Type"]
|
|
41
41
|
} {
|
|
42
42
|
return typedKeysOf(t).reduce(
|
|
43
43
|
(prev, cur) => {
|
|
@@ -49,7 +49,7 @@ export function parseRouteParams<NER extends Record<string, S.Codec<any, any>>>(
|
|
|
49
49
|
return prev
|
|
50
50
|
},
|
|
51
51
|
{} as {
|
|
52
|
-
[K in keyof NER]:
|
|
52
|
+
[K in keyof NER]: NER[K]["Type"]
|
|
53
53
|
}
|
|
54
54
|
)
|
|
55
55
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,54 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { Effect, Layer, Logger } from "effect-app"
|
|
3
|
-
import { CauseException } from "effect-app/client/errors"
|
|
4
|
-
import { type Context } from "effect-app/Context"
|
|
5
|
-
|
|
6
|
-
export function makeAppRuntime<A, E>(layer: Layer.Layer<A, E>) {
|
|
7
|
-
return Effect.gen(function*() {
|
|
8
|
-
const l = layer.pipe(
|
|
9
|
-
Layer.provide(Logger.layer([Logger.consolePretty()]))
|
|
10
|
-
) as Layer.Layer<A, never>
|
|
11
|
-
const mrt = ManagedRuntime.make(l)
|
|
12
|
-
yield* mrt.contextEffect
|
|
13
|
-
return Object.assign(mrt, {
|
|
14
|
-
[Symbol.dispose]() {
|
|
15
|
-
return Effect.runSync(mrt.disposeEffect)
|
|
16
|
-
},
|
|
17
|
-
|
|
18
|
-
[Symbol.asyncDispose]() {
|
|
19
|
-
return mrt.dispose()
|
|
20
|
-
}
|
|
21
|
-
}) // as we initialise here, there is no more error left.
|
|
22
|
-
})
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function initializeSync<A, E>(layer: Layer.Layer<A, E, never>) {
|
|
26
|
-
const runtime = Effect.runSync(makeAppRuntime(layer))
|
|
27
|
-
return runtime
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function initializeAsync<A, E>(layer: Layer.Layer<A, E, never>) {
|
|
31
|
-
return Effect
|
|
32
|
-
.runPromise(makeAppRuntime(layer))
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// we wrap into CauseException because we want to keep the full cause of the failure.
|
|
36
|
-
export const makeRunPromise = <T>(services: Context<T>) =>
|
|
37
|
-
flow(Effect.runPromiseExitWith(services), (_) =>
|
|
38
|
-
_.then(
|
|
39
|
-
Exit.match({
|
|
40
|
-
onFailure: (cause) => Promise.reject(new CauseException(cause, "runPromise")),
|
|
41
|
-
onSuccess: (value) => Promise.resolve(value)
|
|
42
|
-
})
|
|
43
|
-
))
|
|
44
|
-
|
|
45
|
-
export const makeRunSync = <T>(services: Context<T>) =>
|
|
46
|
-
flow(
|
|
47
|
-
Effect.runSyncExitWith(services),
|
|
48
|
-
Exit.match({
|
|
49
|
-
onFailure: (cause) => {
|
|
50
|
-
throw new CauseException(cause, "runSync")
|
|
51
|
-
},
|
|
52
|
-
onSuccess: (value) => value
|
|
53
|
-
})
|
|
54
|
-
)
|
|
1
|
+
export * from "effect-app/runtime"
|
package/src/suspense.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Effect } from "effect-app"
|
|
2
|
+
import * as AsyncResult from "effect/reactivity/AsyncResult"
|
|
3
|
+
import { computed, type ComputedRef, nextTick } from "vue"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The always-defined `data` ref behind a resolved suspense query.
|
|
7
|
+
*
|
|
8
|
+
* A reactive arg can re-point the view at a DIFFERENT cache atom after the suspense promise
|
|
9
|
+
* resolved; the fresh atom starts `Initial` (no `previousSuccess` carried over), so the raw data
|
|
10
|
+
* briefly reads `undefined` while it fetches. The suspense contract ("after the await there is
|
|
11
|
+
* always a value") is kept by serving the last defined value across that transition — TanStack's
|
|
12
|
+
* keepPreviousData, which Vue needs because it cannot re-suspend after mount. Waiting/failure of
|
|
13
|
+
* the new fetch stays observable on the view's `result` ref.
|
|
14
|
+
*
|
|
15
|
+
* Throws only if there has never been a value, which is unreachable once the suspense await
|
|
16
|
+
* resolved successfully.
|
|
17
|
+
*/
|
|
18
|
+
export const latestDefined = <TData>(source: () => TData | undefined, label: string): ComputedRef<TData> => {
|
|
19
|
+
let last: TData | undefined
|
|
20
|
+
return computed<TData>(() => {
|
|
21
|
+
const latest = source()
|
|
22
|
+
if (latest !== undefined) {
|
|
23
|
+
last = latest
|
|
24
|
+
return latest
|
|
25
|
+
}
|
|
26
|
+
if (last !== undefined) return last
|
|
27
|
+
throw new Error(`Internal Error: ${label} resolved without a latest value`)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const awaitResolvedSuspenseResult = <A, E>(
|
|
32
|
+
resultRef: ComputedRef<AsyncResult.AsyncResult<A, E>>
|
|
33
|
+
) =>
|
|
34
|
+
Effect.gen(function*() {
|
|
35
|
+
let result = resultRef.value
|
|
36
|
+
|
|
37
|
+
for (let remainingTicks = 3; remainingTicks > 0 && AsyncResult.isInitial(result); remainingTicks--) {
|
|
38
|
+
yield* Effect.promise(() => nextTick())
|
|
39
|
+
result = resultRef.value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return result
|
|
43
|
+
})
|
package/src/toast.ts
CHANGED
|
@@ -1,52 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { accessEffectFn } from "effect-app/Context"
|
|
3
|
-
|
|
4
|
-
export type ToastId = string | number
|
|
5
|
-
export type ToastOpts = { id?: ToastId; timeout?: number }
|
|
6
|
-
export type ToastOptsInternal = { id?: ToastId | null; timeout?: number }
|
|
7
|
-
|
|
8
|
-
export type UseToast = () => {
|
|
9
|
-
error: (this: void, message: string, options?: ToastOpts) => ToastId
|
|
10
|
-
warning: (this: void, message: string, options?: ToastOpts) => ToastId
|
|
11
|
-
success: (this: void, message: string, options?: ToastOpts) => ToastId
|
|
12
|
-
info: (this: void, message: string, options?: ToastOpts) => ToastId
|
|
13
|
-
dismiss: (this: void, id: ToastId) => void
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export class CurrentToastId extends Context.Opaque<CurrentToastId, { toastId: ToastId }>()("CurrentToastId") {}
|
|
17
|
-
|
|
18
|
-
/** fallback to CurrentToastId when available unless id is explicitly set to a value or null */
|
|
19
|
-
export const wrap = (toast: ReturnType<UseToast>) => {
|
|
20
|
-
const wrap = (toastHandler: (message: string, options?: ToastOpts) => ToastId) => {
|
|
21
|
-
return (message: string, options?: ToastOptsInternal) =>
|
|
22
|
-
Effect.serviceOption(CurrentToastId).pipe(
|
|
23
|
-
Effect.flatMap((currentToast) =>
|
|
24
|
-
Effect.sync(() => {
|
|
25
|
-
const { id: _id, ...rest } = options ?? {}
|
|
26
|
-
const id = _id !== undefined
|
|
27
|
-
? _id ?? undefined
|
|
28
|
-
: Option.getOrUndefined(Option.map(currentToast, (_) => _.toastId))
|
|
29
|
-
// when id is undefined, we may end up with no toast at all..
|
|
30
|
-
return toastHandler(message, id !== undefined ? { ...rest, id } : rest)
|
|
31
|
-
})
|
|
32
|
-
)
|
|
33
|
-
)
|
|
34
|
-
}
|
|
35
|
-
return {
|
|
36
|
-
error: wrap(toast.error),
|
|
37
|
-
info: wrap(toast.info),
|
|
38
|
-
success: wrap(toast.success),
|
|
39
|
-
warning: wrap(toast.warning),
|
|
40
|
-
dismiss: (toastId: ToastId) => Effect.sync(() => toast.dismiss(toastId))
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
type ToastShape = ReturnType<typeof wrap>
|
|
45
|
-
|
|
46
|
-
export class Toast extends Context.Opaque<Toast, ToastShape>()("Toast") {
|
|
47
|
-
static readonly error = accessEffectFn(this, "error")
|
|
48
|
-
static readonly info = accessEffectFn(this, "info")
|
|
49
|
-
static readonly success = accessEffectFn(this, "success")
|
|
50
|
-
static readonly warning = accessEffectFn(this, "warning")
|
|
51
|
-
static readonly dismiss = accessEffectFn(this, "dismiss")
|
|
52
|
-
}
|
|
1
|
+
export * from "effect-app/toast"
|
package/src/withToast.ts
CHANGED
|
@@ -1,99 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { wrapEffect } from "effect-app/utils"
|
|
3
|
-
import { CurrentToastId, Toast } from "./toast.js"
|
|
4
|
-
|
|
5
|
-
export interface ToastOptions<A, E, Args extends ReadonlyArray<unknown>, WaiR, SucR, ErrR> {
|
|
6
|
-
stableToastId?: undefined | string | ((...args: Args) => string | undefined)
|
|
7
|
-
timeout?: number
|
|
8
|
-
onWaiting:
|
|
9
|
-
| string
|
|
10
|
-
| ((...args: Args) => string | null)
|
|
11
|
-
| null
|
|
12
|
-
| ((
|
|
13
|
-
...args: Args
|
|
14
|
-
) => Effect.Effect<string | null, never, WaiR>)
|
|
15
|
-
onSuccess:
|
|
16
|
-
| string
|
|
17
|
-
| ((a: A, ...args: Args) => string | null)
|
|
18
|
-
| null
|
|
19
|
-
| ((
|
|
20
|
-
a: A,
|
|
21
|
-
...args: Args
|
|
22
|
-
) => Effect.Effect<string | null, never, SucR>)
|
|
23
|
-
onFailure:
|
|
24
|
-
| string
|
|
25
|
-
| ((
|
|
26
|
-
error: Option.Option<E>,
|
|
27
|
-
...args: Args
|
|
28
|
-
) => string | { level: "warn" | "error"; message: string })
|
|
29
|
-
| ((
|
|
30
|
-
error: Option.Option<E>,
|
|
31
|
-
...args: Args
|
|
32
|
-
) => Effect.Effect<string | { level: "warn" | "error"; message: string }, never, ErrR>)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// @effect-diagnostics-next-line missingEffectServiceDependency:off
|
|
36
|
-
export class WithToast extends Context.Service<WithToast>()("WithToast", {
|
|
37
|
-
make: Effect.gen(function*() {
|
|
38
|
-
const toast = yield* Toast
|
|
39
|
-
return <A, E, Args extends readonly unknown[], R, WaiR = never, SucR = never, ErrR = never>(
|
|
40
|
-
options: ToastOptions<A, E, Args, WaiR, SucR, ErrR>
|
|
41
|
-
) =>
|
|
42
|
-
Effect.fnUntraced(function*(self: Effect.Effect<A, E, R>, ...args: Args) {
|
|
43
|
-
const baseTimeout = options.timeout ?? 3_000
|
|
44
|
-
|
|
45
|
-
const stableToastId = typeof options.stableToastId === "function"
|
|
46
|
-
? options.stableToastId(...args)
|
|
47
|
-
: options.stableToastId
|
|
48
|
-
|
|
49
|
-
const t = yield* wrapEffect(options.onWaiting)(...args)
|
|
50
|
-
const toastId = t === null ? stableToastId : yield* toast.info(
|
|
51
|
-
t,
|
|
52
|
-
{ id: stableToastId ?? null } // TODO: timeout forever?
|
|
53
|
-
)
|
|
54
|
-
return yield* self.pipe(
|
|
55
|
-
Effect.tap(Effect.fnUntraced(function*(a) {
|
|
56
|
-
const t = yield* wrapEffect(options.onSuccess)(a, ...args)
|
|
57
|
-
if (t === null) {
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
yield* toast.success(
|
|
61
|
-
t,
|
|
62
|
-
toastId !== undefined ? { id: toastId, timeout: baseTimeout } : { timeout: baseTimeout }
|
|
63
|
-
)
|
|
64
|
-
})),
|
|
65
|
-
Effect.tapCause(Effect.fnUntraced(function*(cause) {
|
|
66
|
-
yield* Effect.logDebug(
|
|
67
|
-
"WithToast - caught error cause: " + Cause.squash(cause),
|
|
68
|
-
Cause.hasInterruptsOnly(cause),
|
|
69
|
-
cause
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
if (Cause.hasInterruptsOnly(cause)) {
|
|
73
|
-
if (toastId) yield* toast.dismiss(toastId)
|
|
74
|
-
return
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const t = yield* wrapEffect(options.onFailure)(Cause.findErrorOption(cause), ...args)
|
|
78
|
-
const opts = { timeout: baseTimeout * 2 }
|
|
79
|
-
|
|
80
|
-
if (typeof t === "object") {
|
|
81
|
-
return t.level === "warn"
|
|
82
|
-
? yield* toast.warning(t.message, toastId !== undefined ? { ...opts, id: toastId } : opts)
|
|
83
|
-
: yield* toast.error(t.message, toastId !== undefined ? { ...opts, id: toastId } : opts)
|
|
84
|
-
}
|
|
85
|
-
yield* toast.error(t, toastId !== undefined ? { ...opts, id: toastId } : opts)
|
|
86
|
-
}, Effect.uninterruptible)),
|
|
87
|
-
toastId !== undefined ? Effect.provideService(CurrentToastId, CurrentToastId.of({ toastId })) : (_) => _
|
|
88
|
-
)
|
|
89
|
-
})
|
|
90
|
-
})
|
|
91
|
-
}) {
|
|
92
|
-
static readonly DefaultWithoutDependencies = Layer.effect(this, this.make)
|
|
93
|
-
static readonly Default = this.DefaultWithoutDependencies
|
|
94
|
-
|
|
95
|
-
static readonly handle = <A, E, Args extends Array<unknown>, R, WaiR = never, SucR = never, ErrR = never>(
|
|
96
|
-
options: ToastOptions<A, E, Args, WaiR, SucR, ErrR>
|
|
97
|
-
): (self: Effect.Effect<A, E, R>, ...args: Args) => Effect.Effect<A, E, R | WaiR | SucR | ErrR | WithToast> =>
|
|
98
|
-
(self, ...args) => this.use((_) => _<A, E, Args, R, WaiR, SucR, ErrR>(options)(self, ...args))
|
|
99
|
-
}
|
|
1
|
+
export * from "effect-app/withToast"
|
package/test/Mutation.test.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { it } from "@effect/vitest"
|
|
3
|
-
import
|
|
3
|
+
import * as Effect from "effect-app/Effect"
|
|
4
|
+
import * as Option from "effect-app/Option"
|
|
5
|
+
import * as Cause from "effect/Cause"
|
|
6
|
+
import * as Exit from "effect/Exit"
|
|
7
|
+
import * as Fiber from "effect/Fiber"
|
|
8
|
+
import { TestClock } from "effect/testing"
|
|
4
9
|
import { CommandContext, DefaultIntl } from "../src/commander.js"
|
|
5
10
|
import { AsyncResult } from "../src/lib.js"
|
|
6
|
-
import { useExperimental } from "./stubs.js"
|
|
11
|
+
import { useExperimental, useExperimentalE } from "./stubs.js"
|
|
7
12
|
|
|
8
13
|
const unwrap = <A, E>(r: Fiber.Fiber<Exit.Exit<A, E>, never>) => Fiber.join(r).pipe(Effect.flatten)
|
|
9
14
|
|
|
@@ -317,8 +322,8 @@ it.live("with toasts", () =>
|
|
|
317
322
|
function*() {
|
|
318
323
|
expect(yield* Effect.currentSpan.pipe(Effect.map((_) => _.name))).toBe("Test Action")
|
|
319
324
|
|
|
320
|
-
|
|
321
|
-
expect(toasts
|
|
325
|
+
// fast actions complete before the 1s waiting-toast delay, so no waiting toast is shown
|
|
326
|
+
expect(toasts.length).toBe(0)
|
|
322
327
|
|
|
323
328
|
return "test-value"
|
|
324
329
|
},
|
|
@@ -358,7 +363,7 @@ it.live("interrupted", () =>
|
|
|
358
363
|
|
|
359
364
|
const command = Command.fn("Test Action")(
|
|
360
365
|
function*() {
|
|
361
|
-
expect(toasts.length).toBe(
|
|
366
|
+
expect(toasts.length).toBe(0)
|
|
362
367
|
yield* Effect.interrupt
|
|
363
368
|
return "test-value"
|
|
364
369
|
},
|
|
@@ -385,7 +390,7 @@ it.live("fail", () =>
|
|
|
385
390
|
|
|
386
391
|
const command = Command.fn("Test Action")(
|
|
387
392
|
function*() {
|
|
388
|
-
expect(toasts.length).toBe(
|
|
393
|
+
expect(toasts.length).toBe(0)
|
|
389
394
|
return yield* Effect.fail({ message: "Boom!" })
|
|
390
395
|
},
|
|
391
396
|
Command.withDefaultToast(),
|
|
@@ -400,9 +405,30 @@ it.live("fail", () =>
|
|
|
400
405
|
expect(command.waiting).toBe(false)
|
|
401
406
|
expect(Exit.isFailure(AsyncResult.toExit(command.result))).toBe(true)
|
|
402
407
|
expect(toasts.length).toBe(1) // toast should show error
|
|
403
|
-
expect(toasts[0].
|
|
408
|
+
expect(toasts[0].type).toBe("warning")
|
|
409
|
+
expect(toasts[0].message).toContain("Test Action Failed:\nBoom!")
|
|
410
|
+
expect(toasts[0].message).toMatch(/Trace: [a-f0-9]{32}/)
|
|
411
|
+
expect(toasts[0].message).toMatch(/Span: [a-f0-9]{16}/)
|
|
404
412
|
}))
|
|
405
413
|
|
|
414
|
+
it.live("fail with showSpanInfo disabled", () =>
|
|
415
|
+
Effect
|
|
416
|
+
.gen(function*() {
|
|
417
|
+
const toasts: any[] = []
|
|
418
|
+
const Command = useExperimental({ toasts, messages: DefaultIntl.en })
|
|
419
|
+
|
|
420
|
+
const command = Command.fn("Test Action")(
|
|
421
|
+
function*() {
|
|
422
|
+
return yield* Effect.fail({ message: "Boom!" })
|
|
423
|
+
},
|
|
424
|
+
Command.withDefaultToast({ showSpanInfo: false })
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
yield* Fiber.join(command.handle())
|
|
428
|
+
|
|
429
|
+
expect(toasts.length).toBe(1)
|
|
430
|
+
expect(toasts[0].message).toBe("Test Action Failed:\nBoom!")
|
|
431
|
+
}))
|
|
406
432
|
it.live("fail and recover", () =>
|
|
407
433
|
Effect
|
|
408
434
|
.gen(function*() {
|
|
@@ -412,7 +438,7 @@ it.live("fail and recover", () =>
|
|
|
412
438
|
|
|
413
439
|
const command = Command.fn("Test Action")(
|
|
414
440
|
function*() {
|
|
415
|
-
expect(toasts.length).toBe(
|
|
441
|
+
expect(toasts.length).toBe(0)
|
|
416
442
|
return yield* Effect.fail({ message: "Boom!" })
|
|
417
443
|
},
|
|
418
444
|
Effect.orElseSucceed(() => "recovered"), // we recover from the error here, so the final result is success
|
|
@@ -440,7 +466,7 @@ it.live("defect", () =>
|
|
|
440
466
|
|
|
441
467
|
const command = Command.fn("Test Action")(
|
|
442
468
|
function*() {
|
|
443
|
-
expect(toasts.length).toBe(
|
|
469
|
+
expect(toasts.length).toBe(0)
|
|
444
470
|
return yield* Effect.die({ message: "Boom!" })
|
|
445
471
|
},
|
|
446
472
|
Command.withDefaultToast(),
|
|
@@ -456,7 +482,8 @@ it.live("defect", () =>
|
|
|
456
482
|
expect(command.waiting).toBe(false)
|
|
457
483
|
expect(Exit.isFailure(AsyncResult.toExit(command.result))).toBe(true)
|
|
458
484
|
expect(toasts.length).toBe(1) // toast should show error
|
|
459
|
-
expect(toasts[0].
|
|
485
|
+
expect(toasts[0].type).toBe("error")
|
|
486
|
+
expect(toasts[0].message).toContain("Test Action unexpected error, please try again shortly.")
|
|
460
487
|
}))
|
|
461
488
|
|
|
462
489
|
it.live("works with alt", () =>
|
|
@@ -630,8 +657,8 @@ it.live("with toasts with alt", () =>
|
|
|
630
657
|
function*() {
|
|
631
658
|
expect(yield* Effect.currentSpan.pipe(Effect.map((_) => _.name))).toBe("Test Action")
|
|
632
659
|
|
|
633
|
-
|
|
634
|
-
expect(toasts
|
|
660
|
+
// fast actions complete before the 1s waiting-toast delay, so no waiting toast is shown
|
|
661
|
+
expect(toasts.length).toBe(0)
|
|
635
662
|
|
|
636
663
|
return "test-value"
|
|
637
664
|
},
|
|
@@ -668,7 +695,7 @@ it.live("interrupted with alt", () =>
|
|
|
668
695
|
const command = Command.alt("Test Action")(
|
|
669
696
|
Effect.fnUntraced(
|
|
670
697
|
function*() {
|
|
671
|
-
expect(toasts.length).toBe(
|
|
698
|
+
expect(toasts.length).toBe(0)
|
|
672
699
|
// @effect-diagnostics-next-line missingReturnYieldStar:off
|
|
673
700
|
yield* Effect.interrupt
|
|
674
701
|
return "test-value"
|
|
@@ -698,7 +725,7 @@ it.live("fail with alt", () =>
|
|
|
698
725
|
const command = Command.alt("Test Action")(
|
|
699
726
|
Effect.fnUntraced(
|
|
700
727
|
function*() {
|
|
701
|
-
expect(toasts.length).toBe(
|
|
728
|
+
expect(toasts.length).toBe(0)
|
|
702
729
|
return yield* Effect.fail({ message: "Boom!" })
|
|
703
730
|
},
|
|
704
731
|
Command.withDefaultToast(),
|
|
@@ -714,7 +741,8 @@ it.live("fail with alt", () =>
|
|
|
714
741
|
expect(command.waiting).toBe(false)
|
|
715
742
|
expect(Exit.isFailure(AsyncResult.toExit(command.result))).toBe(true)
|
|
716
743
|
expect(toasts.length).toBe(1) // toast should show error
|
|
717
|
-
expect(toasts[0].
|
|
744
|
+
expect(toasts[0].type).toBe("warning")
|
|
745
|
+
expect(toasts[0].message).toContain("Test Action Failed:\nBoom!")
|
|
718
746
|
}))
|
|
719
747
|
|
|
720
748
|
it.live("fail and recover with alt", () =>
|
|
@@ -727,7 +755,7 @@ it.live("fail and recover with alt", () =>
|
|
|
727
755
|
const command = Command.alt("Test Action")(
|
|
728
756
|
Effect.fnUntraced(
|
|
729
757
|
function*() {
|
|
730
|
-
expect(toasts.length).toBe(
|
|
758
|
+
expect(toasts.length).toBe(0)
|
|
731
759
|
return yield* Effect.fail({ message: "Boom!" })
|
|
732
760
|
},
|
|
733
761
|
Effect.orElseSucceed(() => "recovered"), // we recover from the error here, so the final result is success
|
|
@@ -747,6 +775,64 @@ it.live("fail and recover with alt", () =>
|
|
|
747
775
|
expect(toasts[0].message).toBe("Test Action Success")
|
|
748
776
|
}))
|
|
749
777
|
|
|
778
|
+
it.effect("slow action shows waiting toast after the 1s delay", () =>
|
|
779
|
+
Effect
|
|
780
|
+
.gen(function*() {
|
|
781
|
+
const toasts: any[] = []
|
|
782
|
+
const Command = yield* useExperimentalE({ toasts, messages: DefaultIntl.en })
|
|
783
|
+
|
|
784
|
+
const command = Command.fn("Test Action")(
|
|
785
|
+
function*() {
|
|
786
|
+
// before 1s the waiting toast must NOT have surfaced yet
|
|
787
|
+
expect(toasts.length).toBe(0)
|
|
788
|
+
yield* Effect.sleep("1100 millis")
|
|
789
|
+
// after the delay window the waiting info toast should be visible
|
|
790
|
+
expect(toasts.length).toBe(1)
|
|
791
|
+
expect(toasts[0].type).toBe("info")
|
|
792
|
+
expect(toasts[0].message).toBe("Test Action executing...")
|
|
793
|
+
return "test-value"
|
|
794
|
+
},
|
|
795
|
+
Command.withDefaultToast()
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
const fiber = command.handle()
|
|
799
|
+
yield* TestClock.adjust("2 seconds")
|
|
800
|
+
const r = yield* unwrap(fiber)
|
|
801
|
+
|
|
802
|
+
expect(r).toBe("test-value")
|
|
803
|
+
// waiting toast is replaced (same id) with the success toast
|
|
804
|
+
expect(toasts.length).toBe(1)
|
|
805
|
+
expect(toasts[0].type).toBe("success")
|
|
806
|
+
expect(toasts[0].message).toBe("Test Action Success")
|
|
807
|
+
}))
|
|
808
|
+
|
|
809
|
+
it.effect("slow failing action surfaces waiting toast then shows error", () =>
|
|
810
|
+
Effect
|
|
811
|
+
.gen(function*() {
|
|
812
|
+
const toasts: any[] = []
|
|
813
|
+
const Command = yield* useExperimentalE({ toasts, messages: DefaultIntl.en })
|
|
814
|
+
|
|
815
|
+
const command = Command.fn("Test Action")(
|
|
816
|
+
function*() {
|
|
817
|
+
expect(toasts.length).toBe(0)
|
|
818
|
+
yield* Effect.sleep("1100 millis")
|
|
819
|
+
expect(toasts.length).toBe(1)
|
|
820
|
+
expect(toasts[0].type).toBe("info")
|
|
821
|
+
return yield* Effect.fail({ message: "Boom!" })
|
|
822
|
+
},
|
|
823
|
+
Command.withDefaultToast()
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
const fiber = command.handle()
|
|
827
|
+
yield* TestClock.adjust("2 seconds")
|
|
828
|
+
const r = yield* Fiber.join(fiber)
|
|
829
|
+
|
|
830
|
+
expect(Exit.isFailure(r) && Cause.hasFails(r.cause)).toBe(true)
|
|
831
|
+
expect(toasts.length).toBe(1)
|
|
832
|
+
expect(toasts[0].type).toBe("warning")
|
|
833
|
+
expect(toasts[0].message).toContain("Test Action Failed:\nBoom!")
|
|
834
|
+
}))
|
|
835
|
+
|
|
750
836
|
it.live("defect with alt", () =>
|
|
751
837
|
Effect
|
|
752
838
|
.gen(function*() {
|
|
@@ -774,7 +860,8 @@ it.live("defect with alt", () =>
|
|
|
774
860
|
expect(command.waiting).toBe(false)
|
|
775
861
|
expect(Exit.isFailure(AsyncResult.toExit(command.result))).toBe(true)
|
|
776
862
|
expect(toasts.length).toBe(1) // toast should show error
|
|
777
|
-
expect(toasts[0].
|
|
863
|
+
expect(toasts[0].type).toBe("error")
|
|
864
|
+
expect(toasts[0].message).toContain("Test Action unexpected error, please try again shortly.")
|
|
778
865
|
}))
|
|
779
866
|
|
|
780
867
|
describe("state-in-toast", () => {
|