@effect-app/vue 4.0.0 → 4.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-app/vue",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -15,17 +15,17 @@
15
15
  "@vueuse/core": "^14.3.0",
16
16
  "change-case": "^5.4.4",
17
17
  "query-string": "^9.4.0",
18
- "effect-app": "4.0.0"
18
+ "effect-app": "4.0.2"
19
19
  },
20
20
  "peerDependencies": {
21
- "@effect/atom-vue": "^4.0.1",
22
- "@effect/platform-browser": "^4.0.1",
21
+ "@effect/atom-vue": "^4.0.2",
22
+ "@effect/platform-browser": "^4.0.2",
23
23
  "@sentry/browser": "^10.55.0",
24
- "effect": "^4.0.1",
24
+ "effect": "^4.0.2",
25
25
  "vue": "^3.5.35"
26
26
  },
27
27
  "devDependencies": {
28
- "@effect/vitest": "4.0.1",
28
+ "@effect/vitest": "4.0.2",
29
29
  "@formatjs/icu-messageformat-parser": "^3.5.10",
30
30
  "@types/node": "25.9.1",
31
31
  "@vitejs/plugin-vue": "^6.0.7",
package/src/commander.ts CHANGED
@@ -10,6 +10,7 @@ import * as Layer from "effect-app/Layer"
10
10
  import * as Option from "effect-app/Option"
11
11
  import * as S from "effect-app/Schema"
12
12
  import { isGeneratorFunction, wrapEffect } from "effect-app/utils"
13
+ import { makeWaitingGate, suspendWaiting, WaitingGate } from "effect-app/waitingGate"
13
14
  import * as Cause from "effect/Cause"
14
15
  import type * as Exit from "effect/Exit"
15
16
  import type * as Fiber from "effect/Fiber"
@@ -2114,6 +2115,18 @@ export const CommanderStatic = {
2114
2115
  updateProgress: (progress: Progress | undefined): Effect.Effect<void> =>
2115
2116
  CommandProgress.use((s) => s.update(progress)),
2116
2117
 
2118
+ /**
2119
+ * Park on an effect without the spinner or the waiting toast.
2120
+ * The button stays disabled. Chrome returns when the effect finishes.
2121
+ * Stream commands pause the spinner only. `withDefaultToastStream` does not.
2122
+ *
2123
+ * ```ts
2124
+ * const decision = yield* Command.suspendWaiting(ask(pickupDate))
2125
+ * yield* close(orderId)
2126
+ * ```
2127
+ */
2128
+ suspendWaiting,
2129
+
2117
2130
  /** Version of @see confirmOrInterrupt that automatically includes the action name in the default messages */
2118
2131
  confirmOrInterrupt: Effect.fnUntraced(function*(
2119
2132
  message?: string
@@ -2509,6 +2522,30 @@ const unregisterWait = (id: string) => {
2509
2522
  }
2510
2523
  }
2511
2524
 
2525
+ /**
2526
+ * `waiting` is the spinner. It drops while `suspendWaiting` is parked.
2527
+ * `blocked` stays on for the whole run, including that pause.
2528
+ */
2529
+ const commandChrome = (
2530
+ ownRunning: () => boolean,
2531
+ waitId: string | undefined,
2532
+ blockId: string | undefined
2533
+ ) => {
2534
+ const quiet = ref(0)
2535
+ const gate = makeWaitingGate((depth) => {
2536
+ quiet.value = depth
2537
+ })
2538
+ const running = computed(() => ownRunning() || (waitId !== undefined && (waitState.value[waitId] ?? 0) > 0))
2539
+ const waiting = computed(() => running.value && quiet.value === 0)
2540
+ const blocked = computed(() => running.value || (blockId !== undefined && (waitState.value[blockId] ?? 0) > 0))
2541
+ const provide = <A, E, R>(self: Effect.Effect<A, E, R>) =>
2542
+ Effect.gen(function*() {
2543
+ if (gate.depth !== 0) yield* gate.add(-gate.depth)
2544
+ return yield* self.pipe(Effect.provideService(WaitingGate, gate))
2545
+ })
2546
+ return { waiting, blocked, provide }
2547
+ }
2548
+
2512
2549
  const getStateValues = <
2513
2550
  const Id extends string,
2514
2551
  const I18nKey extends string,
@@ -2658,6 +2695,7 @@ export class CommanderImpl<RT, RTHooks> {
2658
2695
  const blockId = options?.blockKey ? options.blockKey(id) : undefined
2659
2696
 
2660
2697
  const [result, exec_] = asResult(theHandler)
2698
+ const chrome = commandChrome(() => result.value.waiting, waitId, blockId)
2661
2699
 
2662
2700
  const exec = Effect
2663
2701
  .fnUntraced(
@@ -2666,7 +2704,7 @@ export class CommanderImpl<RT, RTHooks> {
2666
2704
  if (blockId !== undefined && blockId !== waitId) {
2667
2705
  registerWait(blockId)
2668
2706
  }
2669
- return yield* exec_(...args)
2707
+ return yield* chrome.provide(exec_(...args))
2670
2708
  },
2671
2709
  Effect.onExit(() =>
2672
2710
  Effect.sync(() => {
@@ -2678,13 +2716,7 @@ export class CommanderImpl<RT, RTHooks> {
2678
2716
  )
2679
2717
  )
2680
2718
 
2681
- const waiting = waitId !== undefined
2682
- ? computed(() => result.value.waiting || (waitState.value[waitId] ?? 0) > 0)
2683
- : computed(() => result.value.waiting)
2684
-
2685
- const blocked = blockId !== undefined
2686
- ? computed(() => waiting.value || (waitState.value[blockId] ?? 0) > 0)
2687
- : computed(() => waiting.value)
2719
+ const { blocked, waiting } = chrome
2688
2720
 
2689
2721
  const computeAllowed = options?.allowed
2690
2722
  const allowed = computeAllowed ? computed(() => computeAllowed(id, state)) : true
@@ -2992,6 +3024,7 @@ export class CommanderImpl<RT, RTHooks> {
2992
3024
  const blockId = options?.blockKey ? options.blockKey(id) : undefined
2993
3025
 
2994
3026
  const [result, exec_] = asStreamResult(theStreamHandler)
3027
+ const chrome = commandChrome(() => result.value.waiting, waitId, blockId)
2995
3028
 
2996
3029
  const exec = Effect
2997
3030
  .fnUntraced(
@@ -3000,7 +3033,7 @@ export class CommanderImpl<RT, RTHooks> {
3000
3033
  if (blockId !== undefined && blockId !== waitId) {
3001
3034
  registerWait(blockId)
3002
3035
  }
3003
- return yield* exec_(...args)
3036
+ return yield* chrome.provide(exec_(...args))
3004
3037
  },
3005
3038
  Effect.onExit(() =>
3006
3039
  Effect.sync(() => {
@@ -3012,13 +3045,7 @@ export class CommanderImpl<RT, RTHooks> {
3012
3045
  )
3013
3046
  )
3014
3047
 
3015
- const waiting = waitId !== undefined
3016
- ? computed(() => result.value.waiting || (waitState.value[waitId] ?? 0) > 0)
3017
- : computed(() => result.value.waiting)
3018
-
3019
- const blocked = blockId !== undefined
3020
- ? computed(() => waiting.value || (waitState.value[blockId] ?? 0) > 0)
3021
- : computed(() => waiting.value)
3048
+ const { blocked, waiting } = chrome
3022
3049
 
3023
3050
  const computeAllowed = options?.allowed
3024
3051
  const allowed = computeAllowed ? computed(() => computeAllowed(id, state)) : true
@@ -71,7 +71,9 @@ export function logError<E>(name: string) {
71
71
  return Effect.fnUntraced(
72
72
  function*(cause: Cause.Cause<E>, extras?: Record<string, unknown>) {
73
73
  if (Cause.hasInterruptsOnly(cause)) {
74
- yield* Effect.logDebug("Interrupted").pipe(Effect.annotateLogs(dropUndefined({ extras })))
74
+ yield* Effect.logDebug("Interrupted").pipe(
75
+ Effect.annotateLogs(dropUndefined({ extras }))
76
+ )
75
77
  return
76
78
  }
77
79
  yield* Effect
package/src/makeIntl.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-empty-object-type */
2
2
  import { createIntl, createIntlCache, type Formatters, type IntlFormatters, type MessageDescriptor, type ResolvedIntlConfig } from "@formatjs/intl"
3
- import { typedKeysOf } from "effect-app/utils"
3
+ import * as Struct from "effect/Struct"
4
4
  import { type FormatXMLElementFn, type Options as IntlMessageFormatOptions, type PrimitiveType } from "intl-messageformat"
5
5
  import { type Ref, watch } from "vue"
6
6
  import { translate } from "./form.ts"
@@ -20,7 +20,7 @@ export const makeIntl = <Locale extends string>(
20
20
  ) => {
21
21
  const intlCache = createIntlCache()
22
22
 
23
- const intls = typedKeysOf(messages).reduce(
23
+ const intls = Struct.keys(messages).reduce(
24
24
  (acc, cur) => {
25
25
  acc[cur] = createIntl<Locale>(
26
26
  {
package/src/mutate.ts CHANGED
@@ -3,7 +3,6 @@ import { DataDependencies, type InvalidationKey, InvalidationKeysFromServer, mak
3
3
  import type { ClientForOptions, RequestHandlerWithInput } from "effect-app/client/clientFor"
4
4
  import type { InvalidateQueryInstruction } from "effect-app/client/makeClient"
5
5
  import * as Effect from "effect-app/Effect"
6
- import { tuple } from "effect-app/Function"
7
6
  import * as Option from "effect-app/Option"
8
7
  import { isReadonlyArrayNonEmpty } from "effect/Array"
9
8
  import type * as Cause from "effect/Cause"
@@ -12,6 +11,7 @@ import * as AsyncResult from "effect/reactivity/AsyncResult"
12
11
  import type * as Reactivity from "effect/reactivity/Reactivity"
13
12
  import * as Ref from "effect/Ref"
14
13
  import * as Stream from "effect/Stream"
14
+ import * as Tuple from "effect/Tuple"
15
15
  import { computed, type ComputedRef, shallowRef } from "vue"
16
16
  import { invalidateAndAwait, invalidateSoft } from "./atomQuery.ts"
17
17
  import { getDerivedInvalidationKeys, partitionInvalidationKeys } from "./dependencyMetadata.ts"
@@ -102,7 +102,7 @@ export function make<A, E, R>(self: Effect.Effect<A, E, R>) {
102
102
 
103
103
  const latestSuccess = computed(() => Option.getOrUndefined(AsyncResult.value(result.value)))
104
104
 
105
- return tuple(result, latestSuccess, execute)
105
+ return Tuple.make(result, latestSuccess, execute)
106
106
  }
107
107
 
108
108
  /**
@@ -221,7 +221,7 @@ export const asResult = <Args extends readonly any[], A, E, R>(
221
221
  ))
222
222
  )
223
223
 
224
- return tuple(computed(() => state.value), act)
224
+ return Tuple.make(computed(() => state.value), act)
225
225
  }
226
226
 
227
227
  /**
@@ -268,7 +268,7 @@ export const asStreamResult = <Args extends readonly any[], A, E, R>(
268
268
 
269
269
  const act = (...args: Args) => runStream(handler(...args))
270
270
 
271
- return tuple(computed(() => state.value), act)
271
+ return Tuple.make(computed(() => state.value), act)
272
272
  }
273
273
 
274
274
  const buildInvalidateCache = <RInvalidator>(
@@ -1,7 +1,7 @@
1
1
  import * as Option from "effect-app/Option"
2
2
  import * as S from "effect-app/Schema"
3
- import { typedKeysOf } from "effect-app/utils"
4
3
  import { flow } from "effect/Function"
4
+ import * as Struct from "effect/Struct"
5
5
  import type { ParsedQuery } from "query-string"
6
6
 
7
7
  export function getQueryParam(search: ParsedQuery, param: string) {
@@ -18,10 +18,10 @@ export function parseRouteParamsOption<NER extends Record<string, S.Codec<any, a
18
18
  ): {
19
19
  [K in keyof NER]: Option.Option<NER[K]["Type"]>
20
20
  } {
21
- return typedKeysOf(t).reduce(
21
+ return Struct.keys(t).reduce(
22
22
  (prev, cur) => {
23
23
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
24
- prev[cur] = getQueryParamO(query, cur as string).pipe(
24
+ prev[cur] = getQueryParamO(query, cur).pipe(
25
25
  Option.flatMap(S.decodeUnknownOption(t[cur]!))
26
26
  )
27
27
 
@@ -39,7 +39,7 @@ export function parseRouteParams<NER extends Record<string, S.Codec<any, any>>>(
39
39
  ): {
40
40
  [K in keyof NER]: NER[K]["Type"]
41
41
  } {
42
- return typedKeysOf(t).reduce(
42
+ return Struct.keys(t).reduce(
43
43
  (prev, cur) => {
44
44
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
45
45
  prev[cur] = S.decodeUnknownSync(t[cur]!)(
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suspendWaiting.test.d.ts","sourceRoot":"","sources":["../suspendWaiting.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,142 @@
1
+ import { it } from "@effect/vitest"
2
+ import * as Effect from "effect-app/Effect"
3
+ import * as Deferred from "effect/Deferred"
4
+ import * as Exit from "effect/Exit"
5
+ import * as Fiber from "effect/Fiber"
6
+ import { TestClock } from "effect/testing"
7
+ import { DefaultIntl } from "../src/commander.js"
8
+ import { useExperimentalE } from "./stubs.js"
9
+
10
+ const unwrap = <A, E>(r: Fiber.Fiber<Exit.Exit<A, E>, never>) => Fiber.join(r).pipe(Effect.flatten)
11
+
12
+ const infoToasts = (toasts: ReadonlyArray<{ type: string }>) => toasts.filter((toast) => toast.type === "info")
13
+
14
+ describe("Command.suspendWaiting", () => {
15
+ it.effect("hides the spinner and the waiting toast until work resumes", () =>
16
+ Effect.gen(function*() {
17
+ const toasts: Array<{ type: string; message: string }> = []
18
+ const Command = yield* useExperimentalE({ toasts, messages: DefaultIntl.en })
19
+ const toastVisible = yield* Deferred.make<void>()
20
+ const continueToDialog = yield* Deferred.make<void>()
21
+ const suspended = yield* Deferred.make<void>()
22
+ const release = yield* Deferred.make<void>()
23
+ const resumed = yield* Deferred.make<void>()
24
+
25
+ const command = Command.fn("Test Action")(
26
+ function*() {
27
+ yield* Effect.sleep("1100 millis")
28
+ yield* Deferred.succeed(toastVisible, undefined)
29
+ yield* Deferred.await(continueToDialog)
30
+ yield* Command.suspendWaiting(Effect.gen(function*() {
31
+ yield* Deferred.succeed(suspended, undefined)
32
+ return yield* Deferred.await(release)
33
+ }))
34
+ yield* Deferred.succeed(resumed, undefined)
35
+ expect(infoToasts(toasts)).toHaveLength(0)
36
+ yield* Effect.sleep("1100 millis")
37
+ expect(infoToasts(toasts)).toHaveLength(1)
38
+ return "done"
39
+ },
40
+ Command.withDefaultToast()
41
+ )
42
+
43
+ const fiber = command.handle(undefined)
44
+ yield* TestClock.adjust("1100 millis")
45
+ yield* Deferred.await(toastVisible)
46
+ expect(command.waiting).toBe(true)
47
+ expect(command.blocked).toBe(true)
48
+ expect(infoToasts(toasts)).toHaveLength(1)
49
+
50
+ yield* Deferred.succeed(continueToDialog, undefined)
51
+ yield* Deferred.await(suspended)
52
+ expect(command.waiting).toBe(false)
53
+ expect(command.blocked).toBe(true)
54
+ expect(infoToasts(toasts)).toHaveLength(0)
55
+
56
+ yield* TestClock.adjust("2 seconds")
57
+ expect(infoToasts(toasts)).toHaveLength(0)
58
+ expect(command.waiting).toBe(false)
59
+
60
+ yield* Deferred.succeed(release, undefined)
61
+ yield* Deferred.await(resumed)
62
+ expect(command.waiting).toBe(true)
63
+ expect(command.blocked).toBe(true)
64
+
65
+ yield* TestClock.adjust("1100 millis")
66
+ expect(yield* unwrap(fiber)).toBe("done")
67
+ expect(toasts.map((toast) => toast.type)).toEqual(["success"])
68
+ }))
69
+
70
+ it.effect("stays quiet for a suspension that starts the command", () =>
71
+ Effect.gen(function*() {
72
+ const toasts: Array<{ type: string; message: string }> = []
73
+ const Command = yield* useExperimentalE({ toasts, messages: DefaultIntl.en })
74
+ const suspended = yield* Deferred.make<void>()
75
+ const release = yield* Deferred.make<string>()
76
+ const resumed = yield* Deferred.make<void>()
77
+
78
+ const command = Command.fn("Test Action")(
79
+ function*() {
80
+ const answer = yield* Command.suspendWaiting(Effect.gen(function*() {
81
+ yield* Deferred.succeed(suspended, undefined)
82
+ return yield* Deferred.await(release)
83
+ }))
84
+ yield* Deferred.succeed(resumed, undefined)
85
+ expect(infoToasts(toasts)).toHaveLength(0)
86
+ yield* Effect.sleep("1100 millis")
87
+ expect(infoToasts(toasts)).toHaveLength(1)
88
+ return answer
89
+ },
90
+ Command.withDefaultToast()
91
+ )
92
+
93
+ const fiber = command.handle(undefined)
94
+ yield* Deferred.await(suspended)
95
+ expect(command.waiting).toBe(false)
96
+ expect(command.blocked).toBe(true)
97
+
98
+ yield* TestClock.adjust("2 seconds")
99
+ expect(infoToasts(toasts)).toHaveLength(0)
100
+
101
+ yield* Deferred.succeed(release, "yes")
102
+ yield* Deferred.await(resumed)
103
+ expect(command.waiting).toBe(true)
104
+
105
+ yield* TestClock.adjust("1100 millis")
106
+ expect(yield* unwrap(fiber)).toBe("yes")
107
+ expect(toasts.map((toast) => toast.type)).toEqual(["success"])
108
+ }))
109
+
110
+ it.effect("drops the block when the suspension is interrupted", () =>
111
+ Effect.gen(function*() {
112
+ const toasts: Array<{ type: string }> = []
113
+ const Command = yield* useExperimentalE({ toasts, messages: DefaultIntl.en })
114
+ const suspended = yield* Deferred.make<void>()
115
+
116
+ const release = yield* Deferred.make<void>()
117
+
118
+ const command = Command.fn("Test Action")(
119
+ function*() {
120
+ return yield* Command.suspendWaiting(Effect.gen(function*() {
121
+ yield* Deferred.succeed(suspended, undefined)
122
+ yield* Deferred.await(release)
123
+ return yield* Effect.interrupt
124
+ }))
125
+ },
126
+ Command.withDefaultToast()
127
+ )
128
+
129
+ const fiber = command.handle(undefined)
130
+ yield* Deferred.await(suspended)
131
+ expect(command.waiting).toBe(false)
132
+ expect(command.blocked).toBe(true)
133
+ expect(toasts).toHaveLength(0)
134
+
135
+ yield* Deferred.succeed(release, undefined)
136
+ const exit = yield* Fiber.join(fiber)
137
+ expect(Exit.hasInterrupts(exit)).toBe(true)
138
+ expect(command.waiting).toBe(false)
139
+ expect(command.blocked).toBe(false)
140
+ expect(toasts).toHaveLength(0)
141
+ }))
142
+ })